#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; 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; using GameCult.Mesh; using NUnit.Framework; using R3; namespace GameCult.Networking.Tests { public class NetworkingTests { private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development(); private static readonly ClientSecurityOptions DevelopmentClientSecurity = ClientSecurityOptions.Development(); private static Socket BindUdpSocket() { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); socket.ReceiveTimeout = 20; return socket; } private static void PumpRudpHandshake( CultNetRudpSocketTransportConnection client, CultNetRudpSocketTransportConnection server) { for (var attempt = 0; attempt < 20; attempt++) { server.ReceiveOnce(); client.ReceiveOnce(); server.ReceiveOnce(); if (client.Connected && server.Connected) { return; } Task.Delay(5).GetAwaiter().GetResult(); } Assert.Fail("RUDP socket handshake did not complete."); } private static CultNetTransportFrame ReceiveRudpFrame(CultNetRudpSocketTransportConnection transport) { for (var attempt = 0; attempt < 20; attempt++) { var frame = transport.ReceiveOnce(); if (frame != null) { return frame; } Task.Delay(5).GetAwaiter().GetResult(); } Assert.Fail("RUDP socket frame was not delivered."); throw new InvalidOperationException("Unreachable."); } private static TMessage ReceiveRudpSchemaMessage(CultNetRudpSocketTransportConnection transport) where TMessage : class, ICultNetSchemaMessage { for (var attempt = 0; attempt < 20; attempt++) { var message = transport.ReceiveSchemaMessageOnce(); if (message != null) { return message; } Task.Delay(5).GetAwaiter().GetResult(); } Assert.Fail($"RUDP schema message {typeof(TMessage).Name} was not delivered."); throw new InvalidOperationException("Unreachable."); } private static async Task WaitUntilAsync(Func predicate, TimeSpan timeout) { var deadline = DateTimeOffset.UtcNow + timeout; while (DateTimeOffset.UtcNow < deadline) { if (predicate()) { return; } await Task.Delay(TimeSpan.FromMilliseconds(5)); } Assert.Fail("Condition was not satisfied before timeout."); } private static async Task AwaitWithTimeout(Task task, TimeSpan timeout) { var completed = await Task.WhenAny(task, Task.Delay(timeout)); if (completed != task) { Assert.Fail("Task did not complete before timeout."); } return await task; } private static async Task AwaitWithTimeout(Task task, TimeSpan timeout) { var completed = await Task.WhenAny(task, Task.Delay(timeout)); if (completed != task) { Assert.Fail("Task did not complete before timeout."); } await task; } [Test] public void EncryptDecrypt_Roundtrip() { var plaintext = Encoding.UTF8.GetBytes("test"); var nonce = Secret.NewNonce; var encrypted = Secret.EncryptBytes(plaintext, nonce, DevelopmentClientSecurity); var decrypted = Secret.DecryptBytes(encrypted, nonce, DevelopmentClientSecurity); Assert.That(plaintext, Is.EqualTo(decrypted)); } [Test] public void SessionToken_Validates_AndRejectsTampering() { var userId = Guid.NewGuid(); var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), DevelopmentServerSecurity); Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out var expiresAt), Is.True); Assert.That(parsedUserId, Is.EqualTo(userId)); Assert.That(expiresAt, Is.GreaterThan(DateTimeOffset.UtcNow)); var tamperedToken = $"{token}tampered"; Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False); } [Test] public void SessionToken_Validates_SessionVersion() { var userId = Guid.NewGuid(); var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), 42, DevelopmentServerSecurity); Assert.That( Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out _, out var sessionVersion), Is.True); Assert.That(parsedUserId, Is.EqualTo(userId)); Assert.That(sessionVersion, Is.EqualTo(42)); } [Test] public void SessionToken_RejectsExpiredTokens() { var token = Secret.CreateSessionToken(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(-1), DevelopmentServerSecurity); Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out _, out _), Is.False); } [Test] public void SecurityOptions_FromEnvironment_Rejects_MissingSecrets() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); Assert.That( () => ServerSecurityOptions.FromEnvironment(), Throws.TypeOf().With.Message.Contains("Server security configuration is not configured")); } [Test] public void SecurityOptions_FromEnvironment_Rejects_PartialConfiguration() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, "connection-key"), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); Assert.That( () => ServerSecurityOptions.FromEnvironment(), Throws.TypeOf() .With.Message.Contains(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable)); } [Test] public void ServerSecurityOptions_FromEnvironment_Can_OptInto_DevelopmentDefaults() { using var _ = new EnvironmentVariableScope( (ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null), (ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null)); var options = ServerSecurityOptions.FromEnvironment(allowDevelopmentDefaults: true); Assert.That(options.IsDevelopment, Is.True); Assert.That(options.ConnectionKey, Is.Not.Empty); } [Test] public void MessageSerialization_RoundTrips_KnownMessageUnion() { var client = new Client(DevelopmentClientSecurity); var message = new LoginMessage { Nonce = Secret.NewNonce, Auth = Encoding.UTF8.GetBytes("auth"), Password = Encoding.UTF8.GetBytes("password") }; var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var payload = (byte[])serialize.Invoke(null, [message])!; var roundTrip = (LoginMessage)deserialize.Invoke(null, [payload])!; Assert.That(roundTrip.Auth, Is.EqualTo(message.Auth)); Assert.That(roundTrip.Password, Is.EqualTo(message.Password)); Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce)); } [Test] public void MessageSerialization_RoundTrips_SchemaCatalogMessages() { var client = new Client(DevelopmentClientSecurity); var message = new SchemaCatalogResponseMessage { MessageId = "catalog-1", Schemas = [ new SchemaDescriptorMessage { SchemaId = "https://example.test/contracts/example.schema.json", Kind = "shared_contract", SchemaVersion = "example.contract.v0", Title = "Example Contract", WireContracts = ["cultnet.schema.v0", "gamecult.networking.v0"], ContentHash = "deadbeef", SchemaJson = "{\"type\":\"object\"}" } ] }; var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var payload = (byte[])serialize.Invoke(null, [message])!; var roundTrip = (SchemaCatalogResponseMessage)deserialize.Invoke(null, [payload])!; Assert.That(roundTrip.MessageId, Is.EqualTo("catalog-1")); Assert.That(roundTrip.Schemas, Has.Length.EqualTo(1)); Assert.That(roundTrip.Schemas[0].SchemaId, Is.EqualTo("https://example.test/contracts/example.schema.json")); Assert.That(roundTrip.Schemas[0].WireContracts, Is.EqualTo(["cultnet.schema.v0", "gamecult.networking.v0"])); Assert.That(roundTrip.Schemas[0].SchemaJson, Is.EqualTo("{\"type\":\"object\"}")); } [Test] public void MessageSerialization_Rejects_InvalidPayload() { var client = new Client(DevelopmentClientSecurity); var serializationType = client.GetType().Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); Assert.That( () => deserialize.Invoke(null, [new byte[] { 0xC1 }]), Throws.TypeOf() .With.InnerException.InstanceOf()); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_HelloTransportProfile() { var message = new CultNetHelloMessage { RuntimeId = "csharp-test", RuntimeKind = "dotnet", TransportProfiles = [ new CultNetTransportProfile { RuntimeId = "csharp-test", Transports = [ new CultNetTransportDescriptor { TransportId = "test-pipe", Protocol = "tcp_framed", WireContracts = [CultNetWireContracts.SchemaV0], Channels = [ new CultNetTransportChannel { ChannelId = "schema", Delivery = "reliable", Ordering = "ordered" } ] } ] } ] }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetHelloMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.TransportProfiles, Is.Not.Null); Assert.That(roundTrip.TransportProfiles![0].Transports[0].Protocol, Is.EqualTo("tcp_framed")); Assert.That(roundTrip.TransportProfiles[0].Transports[0].Channels[0].Ordering, Is.EqualTo("ordered")); } [Test] public async Task TcpFramedTransportConnection_CarriesSchemaPayloadsWithStats() { var payload = Encoding.UTF8.GetBytes("cultnet-payload"); var profile = CultNetTransportProfiles.CreateTcpFramed( "csharp-transport", new TcpFramedTransportProfileOptions { TransportId = "test-tcp" }); using var stream = new MemoryStream(); var sender = new TcpFramedTransportConnection(stream, profile); await sender.SendAsync("schema", payload); Assert.That(sender.Stats.FramesSent, Is.EqualTo(1)); Assert.That(sender.Stats.BytesSent, Is.EqualTo(payload.Length + 4)); Assert.That( Assert.ThrowsAsync( async () => await sender.SendAsync("unreliable", Array.Empty())), Is.Not.Null); stream.Position = 0; var receiver = new TcpFramedTransportConnection(stream, profile); var frame = await receiver.ReceiveAsync(); Assert.That(frame.ChannelId, Is.EqualTo("schema")); Assert.That(frame.Payload, Is.EqualTo(payload)); Assert.That(receiver.Stats.FramesReceived, Is.EqualTo(1)); Assert.That(receiver.Stats.BytesReceived, Is.EqualTo(frame.Payload.Length + 4)); Assert.That(receiver.Profile.Transports[0].Protocol, Is.EqualTo("tcp_framed")); } [Test] public void RudpPacketCodec_UsesDeterministicReliableOrderedFixture() { var encoded = CultNetRudpPacketCodec.Encode(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Data, ConnectionId = 0x01020304, Sequence = 0x0000002a, Ack = 0x00000029, AckMask = 0x80000001, ChannelId = "schema", Reliable = true, Ordered = true, FragmentId = 7, FragmentIndex = 1, FragmentCount = 3, Payload = Encoding.UTF8.GetBytes("hello") }); Assert.That( Convert.ToHexString(encoded).ToLowerInvariant(), Is.EqualTo("434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f")); var decoded = CultNetRudpPacketCodec.Decode(encoded); Assert.That(decoded.PacketType, Is.EqualTo(CultNetRudpPacketType.Data)); Assert.That(decoded.ConnectionId, Is.EqualTo(0x01020304)); Assert.That(decoded.Sequence, Is.EqualTo(0x0000002a)); Assert.That(decoded.Ack, Is.EqualTo(0x00000029)); Assert.That(decoded.AckMask, Is.EqualTo(0x80000001)); Assert.That(decoded.ChannelId, Is.EqualTo("schema")); Assert.That(decoded.Reliable, Is.True); Assert.That(decoded.Ordered, Is.True); Assert.That(decoded.Sequenced, Is.False); Assert.That(decoded.FragmentId, Is.EqualTo(7)); Assert.That(decoded.FragmentIndex, Is.EqualTo(1)); Assert.That(decoded.FragmentCount, Is.EqualTo(3)); Assert.That(Encoding.UTF8.GetString(decoded.Payload), Is.EqualTo("hello")); } [Test] public void RudpTransportProfile_AdvertisesStateAndRealtimeChannels() { var profile = CultNetTransportProfiles.CreateRudp( "csharp-rudp", new RudpTransportProfileOptions { TransportId = "public-rudp", Host = "127.0.0.1", Port = 7777, MaxPayloadBytes = 1200, MaxFragmentBytes = 1000, MaxPendingReliablePackets = 64 }); Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(profile.Transports[0].ReconnectPolicy?.SchemaVersion, Is.EqualTo("cultnet.reconnect_policy.v0")); Assert.That(profile.Transports[0].ReconnectPolicy?.BaseDelayMs, Is.EqualTo(1_000)); Assert.That( profile.Transports[0].Channels.Select(channel => $"{channel.ChannelId}:{channel.Delivery}:{channel.Ordering}").ToArray(), Is.EqualTo(new[] { "schema:reliable:ordered", "latest:unreliable:sequenced", "realtime:unreliable:unordered" })); Assert.That( profile.Transports[0].Channels.Select(channel => channel.MaxPendingReliablePackets).ToArray(), Is.EqualTo(new int?[] { 64, 64, 64 })); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_OperationEnvelope() { var request = new CultNetOperationRequestMessage { MessageId = "plugin-42", ServiceId = "sai.vn", Operation = "project", PayloadSchema = "gamecult.eve.plugin_abi.request.v1", Payload = "gaZzY2hlbWHEJ2dhbWVjdWx0LmV2ZS5wbHVnaW5fYWJpLnJlcXVlc3QudjE=", SourceRuntimeId = "eve-unity" }; var payload = CultNetSchemaMessageSerialization.Serialize(request); var roundTrip = (CultNetOperationRequestMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo(request.MessageId)); Assert.That(roundTrip.ServiceId, Is.EqualTo("sai.vn")); Assert.That(roundTrip.PayloadSchema, Is.EqualTo("gamecult.eve.plugin_abi.request.v1")); } [Test] public void LiteNetLibTransportProfile_AdvertisesLegacyAndSchemaChannels() { var policy = CultNetReconnectPolicies.CreateDefault("client-policy", maxAttempts: 4); var profile = CultNetTransportProfiles.CreateLiteNetLib( "csharp-litenetlib", new LiteNetLibTransportProfileOptions { TransportId = "game-server", Host = "127.0.0.1", Port = 3075, MaxPayloadBytes = 65_507, ReconnectPolicy = policy }); var transport = profile.Transports.Single(); Assert.That(transport.Protocol, Is.EqualTo("litenetlib")); Assert.That(transport.TransportId, Is.EqualTo("game-server")); Assert.That(transport.Host, Is.EqualTo("127.0.0.1")); Assert.That(transport.Port, Is.EqualTo(3075)); Assert.That(transport.WireContracts, Is.EqualTo(new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 })); Assert.That(transport.ReconnectPolicy, Is.SameAs(policy)); Assert.That( transport.Channels.Select(channel => $"{channel.ChannelId}:{channel.Delivery}:{channel.Ordering}:{channel.MaxPayloadBytes}").ToArray(), Is.EqualTo(new[] { "schema:reliable:ordered:65507", "legacy:reliable:ordered:65507" })); } [Test] public void LiteNetLibClientAndServer_ExposeTransportProfiles() { var policy = CultNetReconnectPolicies.CreateDefault("client-profile", maxAttempts: 3); using var client = new Client(DevelopmentClientSecurity, policy); using var server = new Server(new CultCache(), DevelopmentServerSecurity); var clientTransport = client.TransportProfile.Transports.Single(); Assert.That(clientTransport.Protocol, Is.EqualTo("litenetlib")); Assert.That(clientTransport.TransportId, Is.EqualTo("litenetlib-client")); Assert.That(clientTransport.Host, Is.EqualTo("localhost")); Assert.That(clientTransport.ReconnectPolicy, Is.SameAs(policy)); Assert.That(clientTransport.WireContracts, Is.EqualTo(new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 })); var serverTransport = server.TransportProfile.Transports.Single(); Assert.That(serverTransport.Protocol, Is.EqualTo("litenetlib")); Assert.That(serverTransport.TransportId, Is.EqualTo("litenetlib-server")); Assert.That(serverTransport.Host, Is.EqualTo("0.0.0.0")); Assert.That(serverTransport.Port, Is.EqualTo(3075)); Assert.That(serverTransport.Channels.Select(channel => channel.ChannelId).ToArray(), Is.EqualTo(new[] { "schema", "legacy" })); } [Test] public void LiteNetLibTransportConnection_DecodesSchemaAndLegacyChannels() { var schemaPayload = CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage { RuntimeId = "csharp", RuntimeKind = "csharp", DisplayName = "C#", SupportedMessageVersions = [CultNetSchemaVersions.Hello] }); var schemaFrame = LiteNetLibTransportConnection.Decode(schemaPayload); Assert.That(schemaFrame.ChannelId, Is.EqualTo("schema")); Assert.That( LiteNetLibTransportConnection.DecodeSchema(schemaFrame), Is.TypeOf()); var serializationType = typeof(Client).Assembly .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!; var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! .MakeGenericMethod(typeof(Message)); var legacyPayload = (byte[])serialize.Invoke(null, [new ErrorMessage { Error = "Nope" }])!; var legacyFrame = LiteNetLibTransportConnection.Decode(legacyPayload); Assert.That(legacyFrame.ChannelId, Is.EqualTo("legacy")); Assert.That( LiteNetLibTransportConnection.DecodeLegacy(legacyFrame), Is.TypeOf()); Assert.That( () => LiteNetLibTransportConnection.DecodeSchema(legacyFrame), Throws.InvalidOperationException); Assert.That( () => LiteNetLibTransportConnection.DecodeLegacy(schemaFrame), Throws.InvalidOperationException); } [Test] public void LiteNetLibTransportConnection_TracksInboundFrames() { var payload = CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage { RuntimeId = "csharp", RuntimeKind = "csharp", DisplayName = "C#", SupportedMessageVersions = [CultNetSchemaVersions.Hello] }); var connection = new LiteNetLibTransportConnection(CultNetTransportProfiles.CreateLiteNetLib("stats-test")); var frame = connection.Receive(payload); Assert.That(frame.ChannelId, Is.EqualTo("schema")); Assert.That(frame.Payload, Is.EqualTo(payload)); Assert.That(connection.Stats.FramesReceived, Is.EqualTo(1)); Assert.That(connection.Stats.BytesReceived, Is.EqualTo(payload.Length)); } [Test] public void ReconnectPolicy_ExposesPortableDelayContract() { var policy = CultNetReconnectPolicies.CreateDefault("rudp-default", maxAttempts: 8); Assert.That(policy.SchemaVersion, Is.EqualTo("cultnet.reconnect_policy.v0")); Assert.That(policy.PolicyId, Is.EqualTo("rudp-default")); Assert.That(policy.MaxAttempts, Is.EqualTo(8)); Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 1), Is.EqualTo(1_000)); Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 3, 17), Is.EqualTo(4_017)); Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 9, 999), Is.EqualTo(30_250)); Assert.That(CultNetReconnectPolicies.ComputeDelayMs(policy, 0, -5), Is.EqualTo(1_000)); var json = MessagePack.MessagePackSerializer.ConvertToJson( MessagePack.MessagePackSerializer.Serialize(policy, CultNetSchemaMessageSerialization.Options)); Assert.That(json, Does.Contain("\"schemaVersion\"")); Assert.That(json, Does.Contain("\"policyId\"")); Assert.That(json, Does.Contain("\"maxAttempts\"")); } [Test] public void ReconnectController_SchedulesAttemptsAndReset() { var controller = new CultNetReconnectController( CultNetReconnectPolicies.CreateDefault(maxAttempts: 2)); var first = controller.RecordFailure(10_000); Assert.That(first.Attempt, Is.EqualTo(1)); Assert.That(first.ShouldRetry, Is.True); Assert.That(first.DelayMs, Is.EqualTo(1_000)); Assert.That(first.NextAttemptAtMs, Is.EqualTo(11_000)); Assert.That(first.Exhausted, Is.False); Assert.That(controller.CanAttempt(10_999), Is.False); Assert.That(controller.CanAttempt(11_000), Is.True); var second = controller.RecordFailure(11_000, 17); Assert.That(second.Attempt, Is.EqualTo(2)); Assert.That(second.DelayMs, Is.EqualTo(2_017)); Assert.That(second.NextAttemptAtMs, Is.EqualTo(13_017)); Assert.That(second.ShouldRetry, Is.True); var exhausted = controller.RecordFailure(13_017); Assert.That(exhausted.Attempt, Is.EqualTo(2)); Assert.That(exhausted.ShouldRetry, Is.False); Assert.That(exhausted.DelayMs, Is.EqualTo(0)); Assert.That(exhausted.NextAttemptAtMs, Is.Null); Assert.That(exhausted.Exhausted, Is.True); Assert.That(controller.CanAttempt(99_000), Is.False); controller.Reset(); Assert.That(controller.Attempt, Is.EqualTo(0)); Assert.That(controller.NextAttemptAtMs, Is.Null); Assert.That(controller.Exhausted, Is.False); Assert.That(controller.CanAttempt(99_000), Is.True); } [Test] public void RudpReconnectLoop_ConsumesSharedController() { using var serverSocket = BindUdpSocket(); var remoteEndPoint = serverSocket.LocalEndPoint!; var openedLocalPorts = new List(); const uint connectionId = 0x22334455; using var loop = new CultNetRudpReconnectLoop( () => { var socket = BindUdpSocket(); openedLocalPorts.Add(((IPEndPoint)socket.LocalEndPoint!).Port); return new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-reconnect", Socket = socket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = remoteEndPoint, ConnectionId = connectionId }); }, CultNetReconnectPolicies.CreateDefault(maxAttempts: 2), Encoding.UTF8.GetBytes("join")); var first = loop.Start(); Assert.That(first.Stats.BytesSent, Is.GreaterThan(0)); Assert.That(loop.Transport, Is.SameAs(first)); Assert.That(openedLocalPorts, Has.Count.EqualTo(1)); var decision = loop.HandleClosed(10_000, 17); Assert.That(decision, Is.Not.Null); Assert.That(decision!.Attempt, Is.EqualTo(1)); Assert.That(decision.ShouldRetry, Is.True); Assert.That(decision.DelayMs, Is.EqualTo(1_017)); Assert.That(decision.NextAttemptAtMs, Is.EqualTo(11_017)); Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(1)); Assert.That(loop.ReconnectController.NextAttemptAtMs, Is.EqualTo(11_017)); Assert.That(loop.Transport, Is.Null); Assert.That(loop.ReconnectIfDue(11_016), Is.False); Assert.That(openedLocalPorts, Has.Count.EqualTo(1)); Assert.That(loop.ReconnectIfDue(11_017), Is.True); Assert.That(openedLocalPorts, Has.Count.EqualTo(2)); Assert.That(loop.Transport, Is.Not.Null); loop.MarkConnected(); Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(0)); loop.Stop(); Assert.That(loop.Transport, Is.Null); Assert.That(loop.ReconnectController.Attempt, Is.EqualTo(0)); } [Test] public void RudpSession_HandshakeAcksReliableConnectAndAcceptPackets() { var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x0a0b0c0d, InitialSequence = 1, ResendDelayMs = 50 }); var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x0a0b0c0d, InitialSequence = 100, ResendDelayMs = 50 }); var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join")); Assert.That(connect.PacketType, Is.EqualTo(CultNetRudpPacketType.Connect)); Assert.That(connect.Sequence, Is.EqualTo(1)); Assert.That(client.PendingReliableSequences, Is.EqualTo(new uint[] { 1 })); var accept = server.AcceptConnect(connect, 10, Encoding.UTF8.GetBytes("ok")); Assert.That(accept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept)); Assert.That(accept.Ack, Is.EqualTo(1)); Assert.That(server.Connected, Is.True); Assert.That(server.PendingReliableSequences, Is.EqualTo(new uint[] { 100 })); client.Receive(accept, 20); Assert.That(client.Connected, Is.True); Assert.That(client.PendingReliableSequences, Is.Empty); var ack = client.CreateAck(); Assert.That(ack.Sequence, Is.Zero); Assert.That(ack.Ack, Is.EqualTo(100)); server.Receive(ack, 30); Assert.That(server.PendingReliableSequences, Is.Empty); var firstData = client.Send( "schema", Encoding.UTF8.GetBytes("after-ack"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); Assert.That(firstData.Sequence, Is.EqualTo(2)); } [Test] public void RudpSession_ComputesAckMasksAndClearsPendingReliablePackets() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 7, InitialSequence = 10, ResendDelayMs = 100 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 7, InitialSequence = 200, ResendDelayMs = 100 }); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var third = sender.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); Assert.That(sender.PendingReliableSequences, Is.EqualTo(new uint[] { 11, 12, 13 })); receiver.Receive(first); receiver.Receive(third); var ackWithGap = receiver.CreateAck(); Assert.That(ackWithGap.Ack, Is.EqualTo(13)); Assert.That(ackWithGap.AckMask, Is.EqualTo(0b110u)); sender.Receive(ackWithGap); Assert.That(sender.PendingReliableSequences, Is.EqualTo(new uint[] { 12 })); receiver.Receive(second); var fullAck = receiver.CreateAck(); Assert.That(fullAck.Ack, Is.EqualTo(13)); Assert.That(fullAck.AckMask, Is.EqualTo(0b111u)); sender.Receive(fullAck); Assert.That(sender.PendingReliableSequences, Is.Empty); } [Test] public void RudpSession_SchedulesReliableResendsUntilAcked() { var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 99, InitialSequence = 1, ResendDelayMs = 100 }); session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 99, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0); var sent = session.Send("schema", Encoding.UTF8.GetBytes("payload"), new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 10 }); Assert.That(session.DueResends(90), Is.Empty); Assert.That(session.DueResends(110).Select(packet => packet.Sequence).ToArray(), Is.EqualTo(new[] { sent.Sequence })); Assert.That(session.DueResends(150), Is.Empty); session.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 99, Sequence = 51, Ack = sent.Sequence, ChannelId = "control" }); Assert.That(session.DueResends(250), Is.Empty); } [Test] public void RudpSession_ExplicitlyAcknowledgesRetransmitsOlderThanTheReceiveMask() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 991, InitialSequence = 1, ResendDelayMs = 10 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 991, InitialSequence = 500 }); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); var oldPacket = sender.Send( "snapshot", new byte[] { 1 }, new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 0 }); receiver.Receive(oldPacket); foreach (var sequence in Enumerable.Range(3, 40).Select(value => (uint)value)) { receiver.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Data, ConnectionId = 991, Sequence = sequence, ChannelId = "activity", Reliable = true, Ordered = false, Payload = new[] { (byte)sequence } }); } sender.Receive(receiver.CreateAck()); Assert.That(sender.PendingReliableSequences.First(), Is.EqualTo(2u)); var oldRetransmit = sender.DueResends(10).Single(packet => packet.Sequence == 2); receiver.Receive(oldRetransmit); var exactAck = receiver.CreateAckForReceived(oldRetransmit.Sequence); Assert.That(exactAck.Ack, Is.EqualTo(oldRetransmit.Sequence)); Assert.That(exactAck.AckMask, Is.Zero); sender.Receive(exactAck); Assert.That(sender.PendingReliableSequences, Does.Not.Contain(2u)); } [Test] public async Task RudpSession_ResendsAndAcknowledgementsCanRunConcurrently() { var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 100, InitialSequence = 1, ResendDelayMs = 1 }); session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 100, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0); var packets = Enumerable.Range(0, CultNetRudpSession.ReliableSendWindowPackets) .Select(index => session.Send("schema", Encoding.UTF8.GetBytes(index.ToString()), new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 0 })) .ToArray(); await Task.WhenAll( Task.Run(() => { for (var now = 1; now <= 1000; now++) _ = session.DueResends(now); }), Task.Run(() => { foreach (var packet in packets) { session.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 100, Sequence = 5001, Ack = packet.Sequence, ChannelId = "control" }); } })); Assert.That(session.PendingReliableSequences, Is.Empty); } [Test] public void RudpSession_PingsAndDetectsReceiveTimeout() { var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 101, InitialSequence = 1 }); var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 101, InitialSequence = 100 }); var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join")); var accept = server.AcceptConnect(connect, 10); client.Receive(accept, 20); var ping = client.CreatePing(Encoding.UTF8.GetBytes("pulse")); var pingResult = server.Receive(ping, 30); Assert.That(pingResult.Reply?.PacketType, Is.EqualTo(CultNetRudpPacketType.Pong)); Assert.That(pingResult.Reply?.Payload, Is.EqualTo(Encoding.UTF8.GetBytes("pulse"))); var pongResult = client.Receive(pingResult.Reply!, 40); Assert.That(pongResult.Pong, Is.True); Assert.That(pongResult.PongPayload, Is.EqualTo(Encoding.UTF8.GetBytes("pulse"))); Assert.That(client.CheckTimeout(90, 50), Is.False); Assert.That(client.CheckTimeout(91, 50), Is.True); Assert.That(client.Connected, Is.False); } [Test] public void RudpSession_RepeatedConnectAcknowledgesWhatItCarries() { var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 1 }); var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 100 }); var connect = client.CreateConnect(0, Encoding.UTF8.GetBytes("join")); var accept = server.AcceptConnect(connect, 0); var repeat = CultNetRudpPacketCodec.Decode(CultNetRudpPacketCodec.Encode(connect)); repeat.Ack = accept.Sequence; var reply = server.AcceptConnect(repeat, 1); Assert.That(reply.PacketType, Is.EqualTo(CultNetRudpPacketType.Ack)); Assert.That(server.OutstandingReliablePacketCount, Is.EqualTo(0)); } [Test] public void RudpSession_LossyPacketsCannotCreateReliableOrderedGaps() { var client = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 198, InitialSequence = 1 }); var server = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 198, InitialSequence = 100 }); var connect = client.CreateConnect(0); var accept = server.AcceptConnect(connect, 1); client.Receive(accept, 2); var ping = client.CreatePing(Encoding.UTF8.GetBytes("pulse")); var pong = server.Receive(ping, 3).Reply!; var realtime = server.Send("realtime", Encoding.UTF8.GetBytes("discarded realtime")); var latest = server.Send( "latest", Encoding.UTF8.GetBytes("discarded latest state"), new CultNetRudpSendOptions { Sequenced = true }); var schema = server.Send( "schema", Encoding.UTF8.GetBytes("committed response"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); Assert.That(ping.Sequence, Is.Zero); Assert.That(pong.Sequence, Is.Zero); Assert.That(realtime.Sequence, Is.Zero); Assert.That(latest.Sequence, Is.EqualTo(1)); Assert.That(schema.Sequence, Is.EqualTo(accept.Sequence + 1)); Assert.That( client.Receive(schema, 4).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "committed response" })); } [Test] public void RudpSession_UnreliableSequencedDeliveryIsScopedToItsChannel() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 197, InitialSequence = 50 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 197, InitialSequence = 100 }); var connect = sender.CreateConnect(0); var accept = receiver.AcceptConnect(connect, 1); sender.Receive(accept, 2); var older = sender.Send("latest", Encoding.UTF8.GetBytes("older"), new CultNetRudpSendOptions { Sequenced = true }); var newer = sender.Send("latest", Encoding.UTF8.GetBytes("newer"), new CultNetRudpSendOptions { Sequenced = true }); Assert.That(older.Sequence, Is.EqualTo(1)); Assert.That(newer.Sequence, Is.EqualTo(2)); Assert.That( receiver.Receive(newer, 3).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "newer" })); Assert.That(receiver.Receive(older, 4).Delivered, Is.Empty); } [Test] public void RudpSession_RejectsUnreliableOrderedDelivery() { var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 196 }); session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 196, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0); var error = Assert.Throws(() => session.Send( "schema", Encoding.UTF8.GetBytes("cannot order what will not retransmit"), new CultNetRudpSendOptions { Ordered = true })); Assert.That(error!.Message, Does.Contain("ordered delivery requires reliability")); } [Test] public void RudpSession_BoundsPendingReliablePacketsBeforeEnqueue() { var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 102, InitialSequence = 1, MaxPendingReliablePackets = 2 }); session.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 102, InitialSequence = 900 }).AcceptConnect(session.CreateConnect(0), 0), 0); session.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); session.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var error = Assert.Throws(() => session.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true })); Assert.That(error!.Message, Does.Contain("reliable send queue is full")); Assert.That(session.PendingReliableSequences, Is.EqualTo(new uint[] { 2, 3 })); var fragmented = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 1, MaxPendingReliablePackets = 3 }); fragmented.Receive(new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 103, InitialSequence = 900 }).AcceptConnect(fragmented.CreateConnect(0), 0), 0); error = Assert.Throws(() => fragmented.SendMany( "schema", Encoding.UTF8.GetBytes("fragment-me"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }, maxFragmentBytes: 3)); Assert.That(error!.Message, Does.Contain("reliable send queue is full")); Assert.That(fragmented.PendingReliableSequences, Is.Empty); } [Test] public void RudpSession_SuppressesDuplicatesAndDeliversReliableOrderedPayloadsInSequence() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 123, InitialSequence = 1 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 123, InitialSequence = 100 }); var accept = receiver.AcceptConnect(sender.CreateConnect(0), 0); sender.Receive(accept); var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var third = sender.Send("schema", Encoding.UTF8.GetBytes("third"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); Assert.That( receiver.Receive(first).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "first" })); Assert.That(receiver.Receive(third).Delivered, Is.Empty); Assert.That(receiver.Receive(first).Delivered, Is.Empty); Assert.That( receiver.Receive(second).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "second", "third" })); } [Test] public void RudpSession_SkipsControlPacketsWhileOrderingSchemaPayloads() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 124, InitialSequence = 1 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 124, InitialSequence = 100 }); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); var first = sender.Send("schema", Encoding.UTF8.GetBytes("first"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); var control = sender.CreateAck(); var second = sender.Send("schema", Encoding.UTF8.GetBytes("second"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); Assert.That( receiver.Receive(first).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "first" })); Assert.That(receiver.Receive(control).Delivered, Is.Empty); Assert.That( receiver.Receive(second).Delivered.Select(frame => Encoding.UTF8.GetString(frame.Payload)).ToArray(), Is.EqualTo(new[] { "second" })); } [Test] public void RudpSession_FragmentsAndReassemblesReliableOrderedPayloads() { var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 456, InitialSequence = 1 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 456, InitialSequence = 100 }); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); var packets = sender.SendMany( "schema", Encoding.UTF8.GetBytes("fragment-me-please"), new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 10 }, maxFragmentBytes: 5).ToArray(); Assert.That(packets, Has.Length.EqualTo(4)); Assert.That(packets.Select(packet => packet.FragmentCount).ToArray(), Is.EqualTo(new ushort[] { 4, 4, 4, 4 })); Assert.That(packets.Select(packet => packet.FragmentIndex).ToArray(), Is.EqualTo(new ushort[] { 0, 1, 2, 3 })); Assert.That(packets.All(packet => packet.FragmentId == packets[0].FragmentId), Is.True); Assert.That(receiver.Receive(packets[0]).Delivered, Is.Empty); Assert.That(receiver.Receive(packets[1]).Delivered, Is.Empty); Assert.That(receiver.Receive(packets[2]).Delivered, Is.Empty); var delivered = receiver.Receive(packets[3]).Delivered.ToArray(); Assert.That(delivered, Has.Length.EqualTo(1)); Assert.That(Encoding.UTF8.GetString(delivered[0].Payload), Is.EqualTo("fragment-me-please")); Assert.That(delivered[0].Sequence, Is.EqualTo(packets[0].Sequence)); } [Test] public void RudpSession_AdvancesLargeFragmentSetsThroughBoundedReliableWindow() { const uint connectionId = 457; var sender = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25 }); var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25 }); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); var fragmentCount = CultNetRudpSession.ReliableSendWindowPackets + 17; var payload = Enumerable.Range(0, fragmentCount * 8).Select(index => (byte)(index % 251)).ToArray(); var wire = new Queue(sender.SendMany( "schema", payload, new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 1 }, maxFragmentBytes: 8)); Assert.That(wire, Has.Count.EqualTo(CultNetRudpSession.ReliableSendWindowPackets)); Assert.That(sender.PendingReliableSequences, Has.Count.EqualTo(wire.Count)); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(17)); var delivered = new List(); while (wire.Count > 0) { var admitted = wire.Count; for (var index = 0; index < admitted; index++) delivered.AddRange(receiver.Receive(wire.Dequeue(), 2).Delivered); var acknowledged = sender.Receive(receiver.CreateAck(), 3); foreach (var ready in acknowledged.ReadyToSend) wire.Enqueue(ready); } Assert.That(sender.OutstandingReliablePacketCount, Is.Zero); Assert.That(delivered, Has.Count.EqualTo(1)); Assert.That(delivered[0].Payload, Is.EqualTo(payload)); } // The sender's flow window: a reliable packet goes on the wire only while its sequence is at most 1,023 // above the lowest unacked one and the payload above that sequence stays within 4 MiB. private const int Mib = 1024 * 1024; private static CultNetRudpSession FlowSession(uint initialSequence) => new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x464c4f57, InitialSequence = initialSequence, ResendDelayMs = 25 }); // A sender and a receiver that have shaken hands: the receiver's watermark is seeded by the sender's // Connect, and the sender's Connect is acknowledged, so nothing is pending on the sender. private static (CultNetRudpSession Sender, CultNetRudpSession Receiver) ConnectedFlowPair(uint senderInitialSequence, uint receiverInitialSequence) { var sender = FlowSession(senderInitialSequence); var receiver = FlowSession(receiverInitialSequence); sender.Receive(receiver.AcceptConnect(sender.CreateConnect(0), 0), 0); return (sender, receiver); } private static CultNetRudpSession ConnectedFlowSession(uint initialSequence) => ConnectedFlowPair(initialSequence, 900).Sender; private static IReadOnlyList FlowSend(CultNetRudpSession session, int payloadBytes) => session.SendMany("state", new byte[payloadBytes], new CultNetRudpSendOptions { Reliable = true }); private static CultNetRudpPacket FlowAck(uint sequence) => new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 0x464c4f57, Ack = sequence, ChannelId = "control" }; private static CultNetRudpPacket LoseGAndFillTheSpan(CultNetRudpSession session) { var g = FlowSend(session, 1).Single(); for (uint offset = 1; offset <= 1023; offset++) { Assert.That(FlowSend(session, 1).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); session.Receive(FlowAck(g.Sequence + offset), 1); } return g; } [Test] public void RudpSession_ALostPacketHoldsTheSender1023SequencesAheadAndIsStillDelivered() { var (sender, receiver) = ConnectedFlowPair(1, 100); var g = FlowSend(sender, 1).Single(); var admittedAfterG = 0; for (var index = 0; index < 4200; index++) { foreach (var packet in FlowSend(sender, 1)) { admittedAfterG++; receiver.Receive(packet, 1); sender.Receive(receiver.CreateAck(), 1); } } Assert.That(admittedAfterG, Is.EqualTo(1023)); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(4200 - 1023)); var retransmit = sender.DueResends(1000).Single(packet => packet.Sequence == g.Sequence); var delivered = receiver.Receive(retransmit, 1000).Delivered; Assert.That(delivered.Select(frame => frame.Sequence), Is.EqualTo(new[] { g.Sequence })); var promoted = sender.Receive(receiver.CreateAckForReceived(g.Sequence), 1001).ReadyToSend; Assert.That(promoted.First().Sequence, Is.EqualTo(g.Sequence + 1024)); } [Test] public void RudpSession_ADirectSendPastTheSpanIsRefusedWithoutConsumingASequence() { var sender = ConnectedFlowSession(1); var g = LoseGAndFillTheSpan(sender); Assert.Throws(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true })); Assert.That(sender.QueuedReliablePacketCount, Is.Zero); sender.Receive(FlowAck(g.Sequence), 2); var next = sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true }); Assert.That(next.Sequence, Is.EqualTo(g.Sequence + 1024)); } [Test] public void RudpSession_PayloadAboveTheLowestUnackedSequenceIsBoundedBy4MiB() { var sender = ConnectedFlowSession(1); var g = FlowSend(sender, Mib).Single(); // The lowest unacked packet's own bytes do not count: four more MiB fit above it. for (var index = 0; index < 4; index++) Assert.That(FlowSend(sender, Mib), Has.Count.EqualTo(1)); Assert.That(FlowSend(sender, Mib), Is.Empty); Assert.That(FlowSend(sender, Mib), Is.Empty); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(2)); // Acking g moves the floor up one packet: exactly one MiB more fits. var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); } [Test] public void RudpSession_APacketLargerThanTheWindowGoesOutAloneAndWaitsBehindAnythingUnacked() { Assert.That(FlowSend(ConnectedFlowSession(1), 5 * Mib), Has.Count.EqualTo(1)); var sender = ConnectedFlowSession(1); var g = FlowSend(sender, 1).Single(); Assert.That(FlowSend(sender, 5 * Mib), Is.Empty); var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 1 })); } [Test] public void RudpSession_ASmallPacketDoesNotOvertakeAQueuedLargeOne() { var sender = ConnectedFlowSession(1); var g = FlowSend(sender, 1).Single(); Assert.That(FlowSend(sender, 3 * Mib), Has.Count.EqualTo(1)); Assert.That(FlowSend(sender, 2 * Mib), Is.Empty); // A direct send refuses instead of queueing behind it, and takes no sequence. Assert.Throws(() => sender.Send("state", new byte[1], new CultNetRudpSendOptions { Reliable = true })); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); // One byte would fit above g, but the 2 MiB packet is ahead of it. Assert.That(FlowSend(sender, 1), Is.Empty); var promoted = sender.Receive(FlowAck(g.Sequence), 1).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 2, g.Sequence + 3 })); } [Test] public void RudpSession_AcknowledgedBytesAboveALostPacketStillCountAgainst4MiB() { var sender = ConnectedFlowSession(1); var g = FlowSend(sender, 1).Single(); // g is lost. Each 1 MiB frame above it is delivered and acknowledged, and the receiver still holds it // behind the gap, so it keeps counting. for (uint offset = 1; offset <= 4; offset++) { Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); sender.Receive(FlowAck(g.Sequence + offset), 1); } Assert.That(FlowSend(sender, Mib), Is.Empty); Assert.That(sender.QueuedReliablePacketCount, Is.EqualTo(1)); Assert.Throws(() => sender.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true })); // g arrives: nothing is held behind a gap any more, and the queue drains. var promoted = sender.Receive(FlowAck(g.Sequence), 2).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); // The bytes above the old gap no longer count: another MiB fits. Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 })); } [Test] public void RudpSession_AcknowledgedBytesCountAgainstTheLowestUnackedSequenceAsItAdvances() { var sender = ConnectedFlowSession(1); var g = FlowSend(sender, 1).Single(); var h = FlowSend(sender, 1).Single(); for (var index = 0; index < 3; index++) FlowSend(sender, Mib); // g and the 3 MiB above h are acknowledged; h is lost. foreach (uint offset in new uint[] { 2, 3, 4, 0 }) sender.Receive(FlowAck(g.Sequence + offset), 1); Assert.That(FlowSend(sender, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 5 })); // 4 MiB sit above h, acknowledged or not: one more byte does not fit. Assert.That(FlowSend(sender, 1), Is.Empty); // h arrives, the floor moves to g + 5, and the byte fits. var promoted = sender.Receive(FlowAck(h.Sequence), 2).ReadyToSend; Assert.That(promoted.Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + 6 })); } // A server that accepts a fresh Connect may send before its Accept is acknowledged, so no acknowledgement of // the new generation has moved the floor yet. Bytes the ended generation had acknowledged above its own lost // packet belong to no receiver's hold any more and must not count. [Test] public void RudpSession_ANewGenerationOnTheServerDoesNotInheritAcknowledgedBytes() { var server = FlowSession(500); var accept = server.AcceptConnect(FlowSession(1).CreateConnect(0), 0); server.Receive(FlowAck(accept.Sequence), 0); var g = FlowSend(server, 1).Single(); for (uint offset = 1; offset <= 4; offset++) { Assert.That(FlowSend(server, Mib).Select(packet => packet.Sequence), Is.EqualTo(new[] { g.Sequence + offset })); server.Receive(FlowAck(g.Sequence + offset), 1); } Assert.That(FlowSend(server, Mib), Is.Empty); var nextAccept = server.AcceptConnect(FlowSession(200_000).CreateConnect(2), 2); Assert.That(nextAccept.PacketType, Is.EqualTo(CultNetRudpPacketType.Accept)); // The Accept is unacknowledged and the only thing pending: 1 MiB fits above it. var sent = server.Send("state", new byte[Mib], new CultNetRudpSendOptions { Reliable = true }); Assert.That(sent.Sequence, Is.EqualTo(nextAccept.Sequence + 1)); } [Test] public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames() { using var serverSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203040; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25 }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25 }); client.Connect(Encoding.UTF8.GetBytes("join")); PumpRudpHandshake(client, server); Assert.That(client.Connected, Is.True); Assert.That(server.Connected, Is.True); client.Send("schema", 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")); server.Send("schema", Encoding.UTF8.GetBytes("server-state")); var clientFrame = ReceiveRudpFrame(client); Assert.That(clientFrame.ChannelId, Is.EqualTo("schema")); Assert.That(Encoding.UTF8.GetString(clientFrame.Payload), Is.EqualTo("server-state")); Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(client.Stats.FramesSent, Is.EqualTo(1)); Assert.That(server.Stats.FramesReceived, Is.EqualTo(1)); } [Test] public void RudpSocketTransport_DropsStraysAndKeepsServingItsPeer() { using var serverSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); using var strayHost = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; var clientEndPoint = clientSocket.LocalEndPoint!; const uint connectionId = 0x10203050; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId }); var foreign = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 0x0BADF10E }); var strays = new[] { Encoding.UTF8.GetBytes("not a rudp packet"), new byte[] { 1 }, CultNetRudpPacketCodec.Encode(foreign.CreateConnect(0, Encoding.UTF8.GetBytes("foreign"))) }; // A stray that arrives before the peer must not become the peer. foreach (var stray in strays) strayHost.SendTo(stray, serverEndPoint); for (var index = 0; index < strays.Length; index++) Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(strays.Length)); client.Connect(Encoding.UTF8.GetBytes("join")); PumpRudpHandshake(client, server); // A moved flow answers from the peer's own address with someone else's session. foreach (var stray in strays) { serverSocket.SendTo(stray, clientEndPoint); clientSocket.SendTo(stray, serverEndPoint); } for (var index = 0; index < strays.Length; index++) { Assert.That(client.ReceiveOnce(), Is.Null); Assert.That(server.ReceiveOnce(), Is.Null); } Assert.That(client.Stats.PacketsDropped, Is.EqualTo(strays.Length)); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2 * strays.Length)); client.Send("schema", Encoding.UTF8.GetBytes("still here")); Assert.That(Encoding.UTF8.GetString(ReceiveRudpFrame(server).Payload), Is.EqualTo("still here")); server.Send("schema", Encoding.UTF8.GetBytes("server still here")); Assert.That(Encoding.UTF8.GetString(ReceiveRudpFrame(client).Payload), Is.EqualTo("server still here")); } /// Whether the packet's ack field and mask cover the sequence. private static bool Acknowledges(CultNetRudpPacket packet, uint sequence) { if (packet.Ack == sequence) return true; return packet.Ack > sequence && packet.Ack - sequence <= 32 && (packet.AckMask & (1u << (int)(packet.Ack - sequence - 1))) != 0; } private static List DrainPackets(Socket socket) { var packets = new List(); var buffer = new byte[65535]; EndPoint from = new IPEndPoint(IPAddress.Any, 0); while (socket.Poll(20_000, SelectMode.SelectRead)) { var received = socket.ReceiveFrom(buffer, ref from); packets.Add(CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray())); } return packets; } /// A reliable frame no session accepts: a fragment set with no fragment id. private static CultNetRudpPacket PoisonedFrame(CultNetRudpSession session) { var packet = session.Send("schema", Encoding.UTF8.GetBytes("poison"), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); packet.FragmentCount = 2; packet.FragmentId = 0; return packet; } [Test] public void RudpPacketCodec_TreatsADeclaredLengthPastInt32AsMalformed() { var wire = CultNetRudpPacketCodec.Encode(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Ack, ConnectionId = 0x10203054, ChannelId = string.Empty }); Assert.That(wire, Has.Length.EqualTo(36)); wire[30] = 0x80; wire[31] = 0; wire[32] = 0; wire[33] = 0; Assert.Throws(() => CultNetRudpPacketCodec.Decode(wire)); using var connectionSocket = BindUdpSocket(); using var listenerSocket = BindUdpSocket(); using var senderSocket = BindUdpSocket(); using var connection = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-connection", Socket = connectionSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = 0x10203054 }); using var listener = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = listenerSocket, ConnectionId = 0x10203054 }); senderSocket.SendTo(wire, connectionSocket.LocalEndPoint!); senderSocket.SendTo(wire, listenerSocket.LocalEndPoint!); Assert.That(connection.ReceiveOnce(), Is.Null); Assert.That(listener.ReceiveOnce(), Is.Null); Assert.That(connection.Stats.PacketsDropped, Is.EqualTo(1)); Assert.That(listener.Stats.PacketsDropped, Is.EqualTo(1)); } [Test] public void RudpServerMode_IsClaimedOnlyByAConnect() { using var serverSocket = BindUdpSocket(); using var orphanSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); const uint connectionId = 0x10203055; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverSocket.LocalEndPoint!, ConnectionId = connectionId }); var orphan = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }); // A right-id packet that is not a Connect must claim nothing. orphanSocket.SendTo(CultNetRudpPacketCodec.Encode(orphan.CreateAck()), serverSocket.LocalEndPoint!); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1)); client.Connect(Encoding.UTF8.GetBytes("join")); PumpRudpHandshake(client, server); Assert.That(client.Connected, Is.True); Assert.That(server.Connected, Is.True); } [Test] public void RudpSocketTransport_EndsTheSessionThatRefusesAReliableFrame() { using var serverSocket = BindUdpSocket(); using var peerSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203056; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId }); var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint); server.ReceiveOnce(); peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0); var poison = PoisonedFrame(peer); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1)); Assert.That(server.Connected, Is.False); Assert.That(server.DisconnectReason, Is.Not.Null); // The peer is told, and the goodbye does not acknowledge the refused frame. var goodbye = DrainPackets(peerSocket).Single(); Assert.That(goodbye.PacketType, Is.EqualTo(CultNetRudpPacketType.Disconnect)); Assert.That(peer.Receive(goodbye, 1).Disconnected, Is.True); Assert.That(Acknowledges(goodbye, poison.Sequence), Is.False, "the goodbye acknowledged the refused frame"); // A retransmit is dropped, not acknowledged. peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2)); Assert.That(DrainPackets(peerSocket), Is.Empty); // A Connect claims the endpoint again. var again = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(again.CreateConnect(0)), serverEndPoint); server.ReceiveOnce(); Assert.That(DrainPackets(peerSocket).Select(p => p.PacketType), Is.EqualTo(new[] { CultNetRudpPacketType.Accept })); } [Test] public void RudpSocketTransportServer_EndsThePeerWhoseReliableFrameItRefuses() { using var serverSocket = BindUdpSocket(); using var peerSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203057; using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = serverSocket, ConnectionId = connectionId }); var ended = new List(); server.PeerDisconnected += ended.Add; var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint); server.ReceiveOnce(); peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0); Assert.That(server.Peers, Has.Count.EqualTo(1)); var poison = PoisonedFrame(peer); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(1)); Assert.That(server.Peers, Is.Empty); Assert.That(ended, Has.Count.EqualTo(1)); var goodbye = DrainPackets(peerSocket).Single(); Assert.That(goodbye.PacketType, Is.EqualTo(CultNetRudpPacketType.Disconnect)); Assert.That(peer.Receive(goodbye, 1).Disconnected, Is.True); Assert.That(Acknowledges(goodbye, poison.Sequence), Is.False, "the goodbye acknowledged the refused frame"); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(poison), serverEndPoint); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.Stats.PacketsDropped, Is.EqualTo(2)); Assert.That(DrainPackets(peerSocket), Is.Empty); } [Test] public void RudpFlushReliable_FailsForAWriteTheEndedSessionForgot() { using var serverSocket = BindUdpSocket(); using var peerSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203058; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId }); var peer = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(peer.CreateConnect(0)), serverEndPoint); server.ReceiveOnce(); peer.Receive(DrainPackets(peerSocket).Single(p => p.PacketType == CultNetRudpPacketType.Accept), 0); server.Send("schema", Encoding.UTF8.GetBytes("never acknowledged")); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(PoisonedFrame(peer)), serverEndPoint); Assert.That(server.ReceiveOnce(), Is.Null); Assert.That(server.DisconnectReason, Is.Not.Null); Assert.Throws(() => server.FlushReliable(TimeSpan.FromMilliseconds(200))); } [Test] public void RudpServerMode_OwesOneReliableAcceptHoweverManyConnectsRepeat() { using var serverSocket = BindUdpSocket(); using var peerSocket = BindUdpSocket(); const uint connectionId = 0x10203059; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId }); var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0); for (var attempt = 0; attempt < 20; attempt++) { peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!); server.ReceiveOnce(); } var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable); Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1)); } [Test] public void RudpListener_OwesOneReliableAcceptPerGenerationAndReportsAReplacedPeer() { using var serverSocket = BindUdpSocket(); using var peerSocket = BindUdpSocket(); const uint connectionId = 0x1020305a; using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = serverSocket, ConnectionId = connectionId }); var ended = new List(); server.PeerDisconnected += ended.Add; var connect = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("join")); for (var attempt = 0; attempt < 20; attempt++) { peerSocket.SendTo(CultNetRudpPacketCodec.Encode(connect), serverSocket.LocalEndPoint!); server.ReceiveOnce(); } var accepts = DrainPackets(peerSocket).Where(p => p.PacketType == CultNetRudpPacketType.Accept && p.Reliable); Assert.That(accepts.Select(p => p.Sequence).Distinct().Count(), Is.EqualTo(1)); Assert.That(server.Peers, Has.Count.EqualTo(1)); Assert.That(ended, Is.Empty); // A different payload from the same endpoint is a new generation, and the old one is reported. var other = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = connectionId }).CreateConnect(0, Encoding.UTF8.GetBytes("rejoin")); peerSocket.SendTo(CultNetRudpPacketCodec.Encode(other), serverSocket.LocalEndPoint!); server.ReceiveOnce(); Assert.That(server.Peers, Has.Count.EqualTo(1)); Assert.That(ended, Has.Count.EqualTo(1)); } [Test] public void RudpConfiguration_RejectsLimitsThatCannotAdmitAConnect() { Assert.Throws(() => new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, InitialSequence = uint.MaxValue })); Assert.Throws(() => new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, MaxPendingReliablePackets = 0 })); Assert.DoesNotThrow(() => new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, InitialSequence = uint.MaxValue - 1, MaxPendingReliablePackets = 1 })); using var socket = BindUdpSocket(); Assert.Throws(() => new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = socket, ConnectionId = 1, InitialSequence = uint.MaxValue })); Assert.Throws(() => new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = socket, ConnectionId = 1, MaxPendingReliablePackets = 0 })); } [Test] public void RudpSocketTransport_CarriesCultNetSchemaMessages() { using var serverSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203043; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-schema-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25 }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-schema-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25 }); client.Connect(Encoding.UTF8.GetBytes("join")); PumpRudpHandshake(client, server); client.SendSchemaMessage(new CultNetSchemaCatalogRequestMessage { MessageId = "schema-request", IncludeSchemaJson = true, Kinds = ["wire_message"] }); var request = ReceiveRudpSchemaMessage(server); Assert.That(request.MessageId, Is.EqualTo("schema-request")); Assert.That(request.IncludeSchemaJson, Is.True); Assert.That(request.Kinds, Is.EqualTo(new[] { "wire_message" })); server.SendSchemaMessage(new CultNetHelloMessage { RuntimeId = "csharp-rudp-schema-server", RuntimeKind = "csharp", DisplayName = "C# RUDP Schema Server", SupportsSchemaCatalog = true, TransportProfiles = [server.Profile] }); var hello = ReceiveRudpSchemaMessage(client); Assert.That(hello.RuntimeId, Is.EqualTo("csharp-rudp-schema-server")); Assert.That(hello.SupportsSchemaCatalog, Is.True); Assert.That(hello.TransportProfiles, Is.Not.Null); var profile = hello.TransportProfiles!.Single(); Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp")); } [Test] public void RudpSocketTransportServer_DemuxesMultiplePeers() { using var serverSocket = BindUdpSocket(); using var firstClientSocket = BindUdpSocket(); using var secondClientSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203045; using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions { RuntimeId = "csharp-rudp-listener", Socket = serverSocket, ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25, MaxFragmentBytes = 1024, AcceptPayload = Encoding.UTF8.GetBytes("accepted") }); using var firstClient = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-listener-client-a", Socket = firstClientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25, MaxFragmentBytes = 1024 }); using var secondClient = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-listener-client-b", Socket = secondClientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 10, ResendDelayMs = 25, MaxFragmentBytes = 1024 }); firstClient.Connect("join-a"); secondClient.Connect("join-b"); for (var attempt = 0; attempt < 40 && (!firstClient.Connected || !secondClient.Connected || server.Peers.Count != 2); attempt++) { server.ReceiveOnce(); firstClient.ReceiveOnce(); secondClient.ReceiveOnce(); server.PollResends(); firstClient.PollResends(); secondClient.PollResends(); Thread.Sleep(5); } Assert.That(firstClient.Connected, Is.True); Assert.That(secondClient.Connected, Is.True); Assert.That(server.Peers, Has.Count.EqualTo(2)); firstClient.SendSchema("first-payload"); secondClient.SendSchema("second-payload"); CultNetRudpSocketServerFrame? firstFrame = null; CultNetRudpSocketServerFrame? secondFrame = null; for (var attempt = 0; attempt < 40 && (firstFrame == null || secondFrame == null); attempt++) { var frame = server.ReceiveOnce(); if (frame != null) { var payload = Encoding.UTF8.GetString(frame.Frame.Payload); if (payload == "first-payload") { firstFrame = frame; } else if (payload == "second-payload") { secondFrame = frame; } } firstClient.ReceiveOnce(); secondClient.ReceiveOnce(); server.PollResends(); firstClient.PollResends(); secondClient.PollResends(); Thread.Sleep(5); } Assert.That(firstFrame, Is.Not.Null); Assert.That(secondFrame, Is.Not.Null); Assert.That(firstFrame!.Peer.RemoteEndPoint, Is.Not.EqualTo(secondFrame!.Peer.RemoteEndPoint)); Assert.That(firstFrame.Frame.ChannelId, Is.EqualTo("schema")); Assert.That(secondFrame.Frame.ChannelId, Is.EqualTo("schema")); server.SendSchema(firstFrame.Peer, "reply-a"); server.SendSchema(secondFrame.Peer, "reply-b"); var firstReply = firstClient.ReceiveSchema(TimeSpan.FromSeconds(1)); var secondReply = secondClient.ReceiveSchema(TimeSpan.FromSeconds(1)); Assert.That(firstReply, Is.Not.Null); Assert.That(secondReply, Is.Not.Null); Assert.That(Encoding.UTF8.GetString(firstReply!), Is.EqualTo("reply-a")); Assert.That(Encoding.UTF8.GetString(secondReply!), Is.EqualTo("reply-b")); Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(server.Stats.FramesReceived, Is.EqualTo(2)); Assert.That(server.Stats.FramesSent, Is.EqualTo(2)); } [Test] public async Task RudpCultNetSchemaServer_Dispatches_SchemaMessages() { using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "csharp-rudp-schema-host", Socket = BindUdpSocket(), ConnectionId = 0x43554c54, InitialSequence = 100, ResendDelayMs = 25, MaxFragmentBytes = 1024 }); var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var controlProgressObserved = 0; server.OnCultNet((request, peer) => { peer.SendCultNet(new CultNetSchemaCatalogResponseMessage { MessageId = request.MessageId, Schemas = [ new CultNetSchemaDescriptor { SchemaId = "rudp.schema.host", Kind = "wire_message", SchemaVersion = "rudp.schema.host.v0", WireContracts = ["cultnet.schema.v0"] } ] }); serverDone.TrySetResult(); }); var serverThread = new Thread(() => { try { while (!serverDone.Task.IsCompleted) { var poll = server.PollAvailableAsync(64).GetAwaiter().GetResult(); if (poll.TransportItemsConsumed > poll.MessagesDispatched) Interlocked.Exchange(ref controlProgressObserved, 1); if (poll.TransportItemsConsumed == 0) Thread.Sleep(5); } } catch (Exception error) { serverDone.TrySetException(error); } }) { IsBackground = true }; serverThread.Start(); using var client = CultNetSchemaClients.CreateRudp(runtimeId: "csharp-rudp-schema-host-client"); var responseCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(response => responseCompletion.TrySetResult(response)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetSchemaCatalogRequestMessage { MessageId = "rudp-schema-host-test", IncludeSchemaJson = false }); var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-host-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.host")); Assert.That(server.Profile.Transports[0].Protocol, Is.EqualTo("rudp")); Assert.That(Volatile.Read(ref controlProgressObserved), Is.EqualTo(1), "connection and ACK traffic must count as transport progress without impersonating an application message"); } [Test] public void RudpCultNetSchemaServer_RejectsNonPositiveDrainBound() { using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { Socket = BindUdpSocket() }); Assert.ThrowsAsync(() => server.PollAvailableAsync(0)); } [Test] public async Task RudpCultNetSchemaServer_AcceptsSequentialShortLivedClients() { using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "csharp-rudp-sequential-host", Socket = BindUdpSocket(), MaxFragmentBytes = 1024 }); server.OnCultNet((request, peer) => peer.SendCultNet(new CultNetSchemaCatalogResponseMessage { MessageId = request.MessageId, Schemas = Array.Empty() })); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { async Task FetchOnce(string messageId) { using var client = CultNetSchemaClients.CreateRudp(runtimeId: $"client-{messageId}"); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(response => completion.TrySetResult(response)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetSchemaCatalogRequestMessage { MessageId = messageId }); var response = await AwaitWithTimeout(completion.Task, TimeSpan.FromSeconds(2)); Assert.That(response.MessageId, Is.EqualTo(messageId)); } await FetchOnce("first"); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); await FetchOnce("second"); } finally { cancellation.Cancel(); serverThread.Join(); } } // A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made // while a batch is being staged is its own write, not part of the batch. [Test] public void CultNetPutInsideCommitPublishesOnlyAfterCommit() { var path = Path.Combine(Path.GetTempPath(), $"cultnet-commit-{Guid.NewGuid():N}.cc"); try { var cache = new CultCache(); cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); var database = new CultNetDatabase(cache); var published = new List(); using var subscription = database.Watch().Subscribe(change => published.Add(change.Key)); var batched = new CultRecordKey("tests:commit:batched"); var put = new CultRecordKey("tests:commit:put"); Assert.That(cache.Commit(batch => { batch.Upsert(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "batched" }, new CultRecordHandle(batched)); database.PutAsync(put, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "put" }).GetAwaiter().GetResult(); Assert.That(published, Is.EqualTo(new[] { put }), "a record staged in the batch was published before the store committed it"); Assert.That(cache.Get(batched), Is.Null); }), Is.True); Assert.That(published, Is.EqualTo(new[] { put, batched }), "the batch publishes when the store commits it"); Assert.That(cache.Get(batched)?.Text, Is.EqualTo("batched")); var reopened = new CultCache(); reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); Assert.That(reopened.Get(batched)?.Text, Is.EqualTo("batched")); Assert.That(reopened.Get(put)?.Text, Is.EqualTo("put")); } finally { if (File.Exists(path)) File.Delete(path); if (File.Exists(path + ".lock")) File.Delete(path + ".lock"); } } [Test] public async Task DatabaseSubscriptionServer_StreamsRecordChangesOverRudp() { var cache = new CultCache(); var database = new CultNetDatabase(cache); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { using var client = CultNetSchemaClients.CreateRudp("database-subscription-client"); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changed.TrySetResult(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); const string recordKey = "tests:subscription:note"; client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-note", SubscriptionId = "note", RecordKeys = [recordKey], IncludeSnapshot = true }); await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.SubscriptionId, Is.EqualTo("note")); Assert.That(update.ChangeKind, Is.EqualTo("added")); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords() { var cache = new CultCache(); var database = new CultNetDatabase(cache); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-authorized-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer( server, database, authorizeRequest: (request, _) => request.ConsumerRuntimeId == "allowed-runtime", authorizeRecord: (_, _, recordKey, _) => recordKey.StartsWith("tests:public:", StringComparison.Ordinal)); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "public-initial" }); await database.PutAsync(new CultRecordKey("tests:private:initial"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "private-initial" }); using var client = CultNetSchemaClients.CreateRudp("database-authorized-subscription-client"); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changed.TrySetResult(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-authorized", SubscriptionId = "authorized", ConsumerRuntimeId = "allowed-runtime", IncludeSnapshot = true }); var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { "tests:public:initial" })); await database.PutAsync(new CultRecordKey("tests:private:live"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "private-live" }); await database.PutAsync(new CultRecordKey("tests:public:live"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "public-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live")); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionServer_ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges() { const string schemaId = "tests.networking_note"; const string recordKeyOne = "tests:authority:one"; const string recordKeyTwo = "tests:authority:two"; const string bodyId = "tests:authority:body"; var authorized = true; var sourceCache = new CultCache(); var sourceDatabase = new CultNetDatabase(sourceCache); await sourceDatabase.PutAsync(new CultRecordKey(recordKeyOne), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-reconciled-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer( server, sourceDatabase, authorizeRequest: (_, _) => authorized, authorizeRecord: (_, _, _, _) => authorized); using var bodyDemand = new CultMeshBodyDemandTracker(subscriptions); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); var transport = CultNetSchemaClients.CreateRudp("database-reconciled-subscription-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); var changes = new ConcurrentQueue(); client.Changed += changes.Enqueue; transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); await AwaitWithTimeout( client.SubscribeAsync( "authority", schemaIds: [schemaId], consumerRuntimeId: "authority-client", bodyIds: [bodyId], supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]), TimeSpan.FromSeconds(2)); Assert.That(targetCache.Get(new CultRecordKey(recordKeyOne)), Is.Not.Null); // R-K: PublishDemand runs after the snapshot is sent, on the server thread, while // SubscribeAsync's completion is driven by the client's receipt of that same snapshot on a // different thread - asserting immediately races the two. Wait for the deterministic // condition (the demand actually being published) instead of assuming order-of-completion // across a network round trip. await WaitUntilAsync(() => bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True); // Drive the record's identity change through the selection itself // (a real rename: the old key's row is deleted, a new key's row is // put), not through a projection delegate, then force the full // reconcile diff that D6 gives the subscription server. await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "renamed" }); await sourceDatabase.DeleteAsync(new CultRecordKey(recordKeyOne)); subscriptions.Reconcile(); await WaitUntilAsync( () => targetCache.Get(new CultRecordKey(recordKeyOne)) == null && targetCache.Get(new CultRecordKey(recordKeyTwo)) != null, TimeSpan.FromSeconds(2)); Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyOne), Is.True); Assert.That(changes.Any(change => change.ChangeKind == "added" && change.RecordKey == recordKeyTwo), Is.True); authorized = false; subscriptions.Reconcile(); await WaitUntilAsync( () => targetCache.Get(new CultRecordKey(recordKeyTwo)) == null, TimeSpan.FromSeconds(2)); // R-K: same race as the HasConsumers=true assertion above, the other direction. await WaitUntilAsync(() => !bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False); var changeCountAfterRevocation = changes.Count; await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "must-not-escape" }); await Task.Delay(100); Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation)); Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null); } finally { cancellation.Cancel(); serverThread.Join(); } } // Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md, // Self's rulings 2026-09-22): flip authorizeRecord's answer per key while the whole request // stays authorized (so DemandActive never toggles), and prove both halves of the diff - // removing a now-unauthorized key and the add loop picking up a newly-authorized one. [Test] public async Task DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority() { const string schemaId = "tests.networking_note"; const string recordKeyA = "tests:record-authority:a"; const string recordKeyB = "tests:record-authority:b"; var authorizedKeys = new HashSet(StringComparer.Ordinal) { recordKeyA }; var cache = new CultCache(); var database = new CultNetDatabase(cache); await database.PutAsync(new CultRecordKey(recordKeyA), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "a" }); await database.PutAsync(new CultRecordKey(recordKeyB), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "b" }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-record-authority-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer( server, database, authorizeRequest: (_, _) => true, authorizeRecord: (_, _, recordKey, _) => authorizedKeys.Contains(recordKey)); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp("database-record-authority-subscription-client"); var changes = new ConcurrentQueue(); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changes.Enqueue(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-record-authority", SubscriptionId = "record-authority", ConsumerRuntimeId = "record-authority-client", SchemaIds = new[] { schemaId }, IncludeSnapshot = true }); var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKeyA })); authorizedKeys.Clear(); authorizedKeys.Add(recordKeyB); subscriptions.Reconcile(); // SendRemoval carries the removed key on the message's own RecordKey field (no // Document); SendUpsert carries the full record under Document. await WaitUntilAsync( () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA) && changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), TimeSpan.FromSeconds(2)); Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA), Is.True); Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), Is.True); } finally { cancellation.Cancel(); serverThread.Join(); } } // R-P (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): one id reaches the // authorizer, and it is the wire id, on both the snapshot and the live path. The binding below // overrides NetworkSchemaNote's wire schema id away from its descriptor's own id // ("tests.networking_note"); the authorizer allowlists only the wire id. Before the fix, the // live fast path (CreateMatchedRecord) passed descriptor.SchemaId instead of the wire id, so a // row delivered by the snapshot was refused on its first live change. [Test] public async Task DatabaseSubscriptionServer_AuthorizesSnapshotAndFirstLiveChangeByTheSameWireId() { const string wireSchemaId = "tests.networking_note.wire-alias"; const string recordKey = "tests:wire-id:note"; var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry, schemaId: wireSchemaId)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-wire-id-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer( server, database, authorizeRequest: (_, _) => true, authorizeRecord: (_, _, _, schemaId) => schemaId == wireSchemaId); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp("database-wire-id-subscription-client"); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changed.TrySetResult(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-wire-id", SubscriptionId = "wire-id", RecordKeys = new[] { recordKey }, IncludeSnapshot = true }); var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKey })); await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "updated" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); } finally { cancellation.Cancel(); serverThread.Join(); } } // R-T(a) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): a removed change is // evaluated without reading a document - it matches on the selection's keys and schemas alone, // and a field predicate counts as unknown rather than excluding the row or throwing // TryGetIndexValue(null). A subscriber holding the row under a selection with a field predicate // must still receive the removal. [Test] public async Task DatabaseSubscriptionServer_DeliversARemovalUnderAFieldPredicateSelectionWithNoException() { const string recordKey = "v1:field-predicate-removal:sword"; var cache = new CultCache(); var database = new CultNetDatabase(cache); await database.PutAsync(new CultRecordKey(recordKey), new CultNetSelectionEvaluatorTests.SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 }); var schemaId = cache.Registry.GetRequired().SchemaId; using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-field-predicate-removal-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp("database-field-predicate-removal-client"); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changes = new ConcurrentQueue(); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changes.Enqueue(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeV1Message { MessageId = "subscribe-field-predicate-removal", SubscriptionId = "field-predicate-removal", IncludeSnapshot = true, Selection = new CultNetSelection { Schemas = new[] { schemaId }, Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } } } }); var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); Assert.That(snapshot.Matched, Is.EqualTo(1u)); // Before R-T(a), evaluating this delete against the field predicate threw // TryGetIndexValue(null) on the server's poll thread instead of delivering a removal. await database.DeleteAsync(new CultRecordKey(recordKey)); await WaitUntilAsync( () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), TimeSpan.FromSeconds(2)); Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), Is.True); } finally { cancellation.Cancel(); serverThread.Join(); } } // R-A (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): CultNetDatabaseServer // answers a v1 snapshot request against the selection's own Fields predicate, not a selection // quietly lowered to v0's two allowlists. CultNetDatabaseServer pairs with Server (the // LiteNetLib/TCP-secured production construction site, per section 1), not RudpCultNetSchemaServer // - matching CultNetDatabaseServer_Creates_Filtered_SnapshotResponse above, this drives // CreateSelectionResponse directly rather than a real client/server round trip. [Test] public async Task CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields() { var cache = new CultCache(); var database = new CultNetDatabase(cache); await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 }); await database.PutAsync(new CultRecordKey("v1:armor"), new CultNetSelectionEvaluatorTests.SelLeafA { Name = "shield", Kind = "armor", Mass = 3 }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message { MessageId = "v1-snapshot", Selection = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } }, Projection = CultNetSelectionProjections.Document } }); Assert.That(response.Matched, Is.EqualTo(1u)); Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { "v1:weapon" })); } // S9 (docs/cultnet-selection-cut.md section 10) / D6: a hop-bearing subscription reconciles the // whole matched set on every change, not only the changed row. "target" does not itself change // here - a new citer is put that cites it - so only the reconcile path (not the single-row fast // path, which would evaluate the citer's own row against a Selection carrying no hop and answer // null, since the citer's schema is excluded by this selection's Schemas filter) can produce the // add this test waits for. [Test] public async Task DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges() { var cache = new CultCache(); var database = new CultNetDatabase(cache); await database.PutAsync(new CultRecordKey("target"), new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 }); var targetSchemaId = cache.Registry.GetRequired().SchemaId; using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-v1-hop-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp("database-v1-hop-subscription-client"); var subscribed = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var changes = new ConcurrentQueue(); client.OnCultNet(message => subscribed.TrySetResult(message)); client.OnCultNet(message => changes.Enqueue(message)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeV1Message { MessageId = "subscribe-v1-hop", SubscriptionId = "hop", IncludeSnapshot = true, Selection = new CultNetSelection { Schemas = new[] { targetSchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } } }); var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); // "target" is not yet cited by anything - the hop-bearing selection matches nothing yet. Assert.That(snapshot.Matched, Is.EqualTo(0u)); // The citer is a different schema entirely, excluded by this selection's own Schemas // filter - the change that must cause "target" to be reported is not to "target" itself. await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter { Name = "citer", Design = new CultRecordRef(new CultRecordKey("target")) }); await WaitUntilAsync( () => changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), TimeSpan.FromSeconds(2)); Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == "target"), Is.True); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription() { var database = new CultNetDatabase(new CultCache()); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-body-demand-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var active = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var withdrawn = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); subscriptions.DemandChanged += demand => { if (demand.Active) active.TrySetResult(demand); else withdrawn.TrySetResult(demand); }; using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { using var client = CultNetSchemaClients.CreateRudp("database-body-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-body", SubscriptionId = "world-body", RecordKeys = ["world:entities", "mesh:body:world:latest"], ConsumerRuntimeId = "eve-unity", BodyIds = ["world"], SupportedBodyTransports = ["SharedMemory", "Network"], IncludeSnapshot = false }); var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); Assert.That(observed.ConsumerRuntimeId, Is.EqualTo("eve-unity")); Assert.That(observed.SubscriptionId, Is.EqualTo("world-body")); Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:entities", "mesh:body:world:latest" })); Assert.That(observed.SchemaIds, Is.Empty); Assert.That(observed.BodyIds, Is.EqualTo(new[] { "world" })); Assert.That(observed.SupportedBodyTransports, Is.EqualTo(new[] { "SharedMemory", "Network" })); Assert.That(observed.SameMachine, Is.True); client.SendCultNet(new CultNetDatabaseUnsubscribeMessage { MessageId = "unsubscribe-body", SubscriptionId = "world-body" }); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity")); Assert.That(removed.Active, Is.False); } finally { cancellation.Cancel(); serverThread.Join(); } } // A load's delivery reaches a subscription observer that needs the lifecycle gate while a subscribe's demand // handler, holding that gate, writes the same cache. Nothing is disposed on failure: disposal would hang too. [Test] public async Task SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock() { var path = Path.Combine(Path.GetTempPath(), $"cultlib-demand-{Guid.NewGuid():N}.cc"); using (var seed = new CultCache()) { seed.AddBackingStore(new SingleFileMessagePackBackingStore(path)); await seed.UpsertAsync(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "loaded" }, new CultRecordHandle(new CultRecordKey("demand:loaded"))); seed.FlushAllBackingStores(); } var cache = new CultCache(); var delivering = new ManualResetEventSlim(); // Subscribed before the database, so it runs before the database's own observer of the same change. using var loadDelivering = cache.Watch().Subscribe(_ => delivering.Set()); var database = new CultNetDatabase(cache); var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "demand-write-server", Socket = BindUdpSocket() }); var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var written = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); subscriptions.DemandChanged += demand => { if (!demand.Active) return; _ = Task.Run(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(path))); if (!delivering.Wait(TimeSpan.FromSeconds(5))) { written.TrySetException(new TimeoutException("the load never started delivering")); return; } cache.UpsertAsync(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "demanded" }, new CultRecordHandle(new CultRecordKey("demand:written"))).GetAwaiter().GetResult(); written.TrySetResult(true); }; using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { using var client = CultNetSchemaClients.CreateRudp("demand-write-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-demand-write", SubscriptionId = "demand-write", RecordKeys = ["demand:other"], IncludeSnapshot = false }); var finished = await Task.WhenAny(written.Task, Task.Delay(TimeSpan.FromSeconds(10))) == written.Task; cancellation.Cancel(); Assert.That(finished, Is.True, "the demand handler's cache write deadlocked with the load's delivery"); await written.Task; } finally { // Cancel/Join before disposing anything the poll thread touches (server), so a failed // assertion above cannot race the thread against a disposed socket (R-K). cancellation.Cancel(); serverThread.Join(); } subscriptions.Dispose(); server.Dispose(); database.Dispose(); cache.Dispose(); File.Delete(path); } [Test] public async Task DatabaseSubscriptionServer_ClassifiesLoopbackTcpDemandAsSameMachine() { var database = new CultNetDatabase(new CultCache()); using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var active = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); subscriptions.DemandChanged += demand => { if (demand.Active) active.TrySetResult(demand); }; using var client = new TcpFramedCultNetSchemaClient(); client.Connect("127.0.0.1", server.LocalEndPoint.Port); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-tcp-body", SubscriptionId = "world-body", ConsumerRuntimeId = "eve-unity", BodyIds = ["world"], SupportedBodyTransports = ["SharedMemory", "Network"], IncludeSnapshot = false }); var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); Assert.That(observed.SameMachine, Is.True); Assert.That(observed.BodyIds, Is.EqualTo(new[] { "world" })); } [Test] public async Task DatabaseSubscriptionServer_ProjectsExactReactiveStateDemandWithoutBodyDemand() { var database = new CultNetDatabase(new CultCache()); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-reactive-demand-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var active = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); subscriptions.DemandChanged += demand => { if (demand.Active) active.TrySetResult(demand); }; using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { using var client = CultNetSchemaClients.CreateRudp("database-reactive-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-reactive", SubscriptionId = "fog-field", RecordKeys = ["world:field:fog"], SchemaIds = ["gamecult.fields.splats.v1"], ConsumerRuntimeId = "eve-unity", IncludeSnapshot = false }); var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" })); Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" })); Assert.That(observed.BodyIds, Is.Empty); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionServer_WithdrawsDemandWhenPeerDisconnects() { var database = new CultNetDatabase(new CultCache()); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-disconnect-demand-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var active = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var withdrawn = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); subscriptions.DemandChanged += demand => { if (demand.Active) active.TrySetResult(demand); else withdrawn.TrySetResult(demand); }; using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { using var client = CultNetSchemaClients.CreateRudp("database-disconnect-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetDatabaseSubscribeMessage { MessageId = "subscribe-disconnect-body", SubscriptionId = "disconnect-world-body", ConsumerRuntimeId = "eve-unity", BodyIds = ["world"], SupportedBodyTransports = ["SharedMemory"], IncludeSnapshot = false }); await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); client.Dispose(); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body")); Assert.That(removed.Active, Is.False); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionClient_ReplicatesInitialAndLiveTypedDocumentsOverRudp() { var sourceCache = new CultCache(); var sourceDatabase = new CultNetDatabase(sourceCache); const string recordKey = "tests:subscription-client:note"; await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-subscription-client-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); var transport = CultNetSchemaClients.CreateRudp("database-subscription-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); var initial = await AwaitWithTimeout( client.SubscribeAsync("notes", recordKeys: new[] { recordKey }), TimeSpan.FromSeconds(2)); Assert.That(initial.OfType().Single().Text, Is.EqualTo("initial")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.TypeOf()); var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.Changed += change => changed.TrySetResult(change); await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.SubscriptionId, Is.EqualTo("notes")); Assert.That(update.Document, Is.TypeOf()); Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live")); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionClient_LiveDeliveryDecodesWithoutWritingReplicaState() { var sourceCache = new CultCache(); var sourceDatabase = new CultNetDatabase(sourceCache); const string recordKey = "tests:subscription-client:live-note"; await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-live-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); var transport = CultNetSchemaClients.CreateRudp("database-live-subscription-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); var initial = await AwaitWithTimeout( client.SubscribeAsync( "live-notes", recordKeys: new[] { recordKey }, deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live), TimeSpan.FromSeconds(2)); Assert.That(initial.OfType().Single().Text, Is.EqualTo("initial")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.Changed += change => changed.TrySetResult(change); await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionClient_LiveValueOwnsExactFilteringCurrentValueAndUnsubscribe() { var sourceCache = new CultCache(); var sourceDatabase = new CultNetDatabase(sourceCache); const string recordKey = "tests:subscription-client:reactive-note"; using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-live-value-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); var transport = CultNetSchemaClients.CreateRudp("database-live-value-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); using var value = await AwaitWithTimeout( client.SubscribeLiveValueAsync("reactive-note", recordKey), TimeSpan.FromSeconds(2)); Assert.That(value.HasValue, Is.False); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); value.Changed += document => changed.TrySetResult(document); await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "initial" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.Text, Is.EqualTo("initial")); Assert.That(value.Current.Text, Is.EqualTo("initial")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); value.Changed += document => updated.TrySetResult(document); await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "updated" }); update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2)); Assert.That(update.Text, Is.EqualTo("updated")); Assert.That(value.Current.Text, Is.EqualTo("updated")); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscriptionClient_ReceivesProviderMaterializedValueFromExactDemand() { var sourceDatabase = new CultNetDatabase(new CultCache()); const string recordKey = "tests:subscription-client:demanded-note"; using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-demanded-value-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); subscriptions.DemandChanged += demand => { if (!demand.Active || !demand.RecordKeys.Contains(recordKey, StringComparer.Ordinal)) return; sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "materialized-on-demand" }).GetAwaiter().GetResult(); }; using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry)); var transport = CultNetSchemaClients.CreateRudp("database-demanded-value-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); using var value = await AwaitWithTimeout( client.SubscribeLiveValueAsync("demanded-note", recordKey), TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2)); Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding() { const string recordKey = "tests:subscription-client:wire-note"; const string wireSchema = "tests.wire_note.v1"; var sourceCache = new CultCache(); var sourceDocuments = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry, wireSchema)); using var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions { DocumentRegistry = sourceDocuments }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-wire-schema-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, sourceDatabase); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } }) { IsBackground = true }; serverThread.Start(); // R-K: the join moves into finally - a failed assertion below must not leave this poll // thread running past the point where `using` disposes the socket it polls, which turns a // test failure into an unhandled-exception host crash. try { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument(targetCache.Registry, wireSchema)); var transport = CultNetSchemaClients.CreateRudp("database-wire-schema-subscription-client"); using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments); transport.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => transport.Connected, TimeSpan.FromSeconds(2)); await AwaitWithTimeout( client.SubscribeAsync("wire-notes", recordKeys: [recordKey], schemaIds: [wireSchema]), TimeSpan.FromSeconds(2)); var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.Changed += change => changed.TrySetResult(change); await sourceDatabase.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "wire-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); Assert.That(update.SchemaId, Is.EqualTo(wireSchema)); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live")); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public void DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias() { var cache = new CultCache(); var documents = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = documents }); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "database-schema-alias-subscription-server", Socket = BindUdpSocket() }); using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); var descriptor = cache.Registry.GetRequired(); var change = new CultNetDatabaseChange( CultNetDatabaseChangeKind.Added, new CultRecordKey("tests:subscription-client:alias-note"), descriptor.SchemaId, database.Shards[0], new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias-live" }, previousDocument: null); var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod( "CreateMatchedRecord", BindingFlags.Instance | BindingFlags.NonPublic)!; var requestType = typeof(CultNetDatabaseSubscriptionServer).GetNestedType( "SubscriptionRequest", BindingFlags.NonPublic)!; var requestCtor = requestType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(); var request = requestCtor.Invoke(new object?[] { new CultNetSelection { Schemas = ["tests.networking_note.v1"], Keys = ["tests:subscription-client:alias-note"] }, "alias-notes", "alias-notes", true, null, null, null, false }); var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[] { change, request, null }); Assert.That(record, Is.Not.Null); Assert.That(record!.SchemaId, Is.EqualTo(descriptor.SchemaId)); Assert.That(record.SchemaName, Is.EqualTo(descriptor.SchemaName)); Assert.That(record.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion)); Assert.That(record.SchemaContentHash, Is.EqualTo(descriptor.ContentHash)); } [Test] public async Task RudpCultNetSchemaServer_DeliversLargeSnapshotResponse() { using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "csharp-rudp-large-snapshot-host", Socket = BindUdpSocket(), MaxFragmentBytes = 1024, MaxPendingReliablePackets = 1024 }); var responseCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var serverFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); server.OnCultNet((request, peer) => peer.SendCultNet( new CultNetSnapshotResponseRawMessage { MessageId = request.MessageId, Documents = [ new CultNetRawDocumentRecord { SchemaId = "test.large.snapshot.v1", RecordKey = "test:large:snapshot", PayloadEncoding = "messagepack", Payload = Enumerable.Range(0, 128 * 1024) .Select(index => (byte)(index % 251)).ToArray() } ] })); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { try { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } } catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-large-snapshot-client", maxFragmentBytes: 1024); client.OnCultNet(response => responseCompletion.TrySetResult(response)); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetSnapshotRequestMessage { MessageId = "large-snapshot", SchemaIds = ["test.large.snapshot.v1"], RecordKeys = ["test:large:snapshot"] }); var completed = await Task.WhenAny(responseCompletion.Task, serverFailure.Task, Task.Delay(5000)); if (completed == serverFailure.Task) throw await serverFailure.Task; if (completed != responseCompletion.Task) Assert.Fail($"Large snapshot timed out; server sent {server.Stats.BytesSent} bytes, received {server.Stats.BytesReceived} bytes, transports {server.Profile.Transports.Count()}."); var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromMilliseconds(1)); await WaitUntilAsync( () => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0, TimeSpan.FromSeconds(2)); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024)); Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024), "A lossless large response must not remain trapped in the reliable resend queue."); } finally { cancellation.Cancel(); serverThread.Join(); } } [Test] public async Task RudpCultNetSchemaServer_SerializesConcurrentFragmentedSendsPerPeer() { const int publisherCount = 4; const int messagesPerPublisher = 4; const int expectedMessages = publisherCount * messagesPerPublisher; using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions { RuntimeId = "csharp-rudp-concurrent-publish-host", Socket = BindUdpSocket(), MaxFragmentBytes = 512, MaxPendingReliablePackets = 1024 }); var receivedIds = new ConcurrentDictionary(); var receivedAll = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var serverFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var cancellation = new CancellationTokenSource(); var serverThread = new Thread(() => { try { while (!cancellation.IsCancellationRequested) { _ = server.PollOnceAsync().GetAwaiter().GetResult(); Thread.Sleep(1); } } catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); try { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-concurrent-publish-client", maxFragmentBytes: 512); client.OnCultNet(response => { if (receivedIds.TryAdd(response.MessageId, 0) && receivedIds.Count == expectedMessages) receivedAll.TrySetResult(true); }); client.Connect("127.0.0.1", server.LocalEndPoint.Port); await WaitUntilAsync(() => client.Connected && server.Peers.Count == 1, TimeSpan.FromSeconds(2)); var peer = server.Peers.Single(); using var sendBarrier = new Barrier(publisherCount); await Task.WhenAll(Enumerable.Range(0, publisherCount).Select(publisher => Task.Run(() => { sendBarrier.SignalAndWait(); for (var message = 0; message < messagesPerPublisher; message++) { var messageId = $"publisher-{publisher}-message-{message}"; server.SendCultNet(peer, new CultNetSnapshotResponseRawMessage { MessageId = messageId, Documents = [ new CultNetRawDocumentRecord { SchemaId = "test.concurrent.publish.v1", RecordKey = $"test:concurrent:{messageId}", PayloadEncoding = "messagepack", Payload = Enumerable.Range(0, 4 * 1024) .Select(index => (byte)((index + publisher + message) % 251)).ToArray() } ] }); } }))); var completed = await Task.WhenAny(receivedAll.Task, serverFailure.Task, Task.Delay(5000)); if (completed == serverFailure.Task) throw await serverFailure.Task; if (completed != receivedAll.Task) Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages."); } finally { cancellation.Cancel(); serverThread.Join(); } Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages)); } [Test] public void RudpSocketTransport_ErgonomicHelpersCarryNamedChannels() { using var serverSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203044; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-helper-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25 }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-helper-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25 }); client.Connect("join"); PumpRudpHandshake(client, server); client.SendSchema("client-state"); var schemaPayload = server.ReceiveSchema(TimeSpan.FromSeconds(1)); Assert.That(schemaPayload, Is.Not.Null); Assert.That(Encoding.UTF8.GetString(schemaPayload!), Is.EqualTo("client-state")); server.SendSchemaMessage(new CultNetHelloMessage { RuntimeId = "csharp-rudp-helper-server", RuntimeKind = "csharp" }); var hello = client.ReceiveSchemaMessage(TimeSpan.FromSeconds(1)); Assert.That(hello, Is.Not.Null); Assert.That(hello!.RuntimeId, Is.EqualTo("csharp-rudp-helper-server")); client.SendLatest("latest-state"); var latest = server.ReceiveUntil( TimeSpan.FromSeconds(1), frame => string.Equals(frame.ChannelId, "latest", StringComparison.Ordinal)); Assert.That(latest, Is.Not.Null); Assert.That(Encoding.UTF8.GetString(latest!.Payload), Is.EqualTo("latest-state")); client.SendRealtime("tick"); var realtime = server.ReceiveUntil( TimeSpan.FromSeconds(1), frame => string.Equals(frame.ChannelId, "realtime", StringComparison.Ordinal)); Assert.That(realtime, Is.Not.Null); Assert.That(Encoding.UTF8.GetString(realtime!.Payload), Is.EqualTo("tick")); } [Test] public void RudpSocketTransport_CarriesFragmentedReliableOrderedSchemaFrames() { using var serverSocket = BindUdpSocket(); using var clientSocket = BindUdpSocket(); var serverEndPoint = serverSocket.LocalEndPoint!; const uint connectionId = 0x10203041; using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-fragment-server", Socket = serverSocket, Mode = CultNetRudpSocketMode.Server, ConnectionId = connectionId, InitialSequence = 100, ResendDelayMs = 25, MaxFragmentBytes = 8 }); using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-fragment-client", Socket = clientSocket, Mode = CultNetRudpSocketMode.Client, RemoteEndPoint = serverEndPoint, ConnectionId = connectionId, InitialSequence = 1, ResendDelayMs = 25, MaxFragmentBytes = 8 }); var payload = Encoding.UTF8.GetBytes("this-schema-frame-is-larger-than-one-rudp-fragment"); client.Connect(Encoding.UTF8.GetBytes("join")); PumpRudpHandshake(client, server); client.Send("schema", payload); var serverFrame = ReceiveRudpFrame(server); Assert.That(serverFrame.ChannelId, Is.EqualTo("schema")); Assert.That(serverFrame.Payload, Is.EqualTo(payload)); Assert.That(client.Stats.FramesSent, Is.EqualTo(1)); Assert.That(server.Stats.FramesReceived, Is.EqualTo(1)); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_RawSnapshotResponse() { var message = new CultNetSnapshotResponseRawMessage { MessageId = "snapshot-1", ShardId = "players-eu", ShardEpoch = 12, ShardLogSequence = 42, Documents = [ new CultNetRawDocumentRecord { SchemaId = "sha256:ghostlight-agent-state", RecordKey = "world/main", StoredAt = "2026-05-06T12:34:56.0000000+00:00", 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.ShardId, Is.EqualTo("players-eu")); Assert.That(roundTrip.ShardEpoch, Is.EqualTo(12)); Assert.That(roundTrip.ShardLogSequence, Is.EqualTo(42)); Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); Assert.That(roundTrip.Documents[0].SchemaId, Is.EqualTo("sha256: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 void CultNetSchemaMessageSerialization_RoundTrips_DatabaseChangeRaw() { var message = new CultNetDatabaseChangeRawMessage { MessageId = "change-1", SubscriptionId = "sub-1", ChangeKind = "updated", Document = new CultNetRawDocumentRecord { SchemaId = "schema-1", RecordKey = "record-1", StoredAt = "2026-05-19T12:00:00.0000000+00:00", PayloadEncoding = "messagepack", Payload = [0x91, 0x01] } }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetDatabaseChangeRawMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.SubscriptionId, Is.EqualTo("sub-1")); Assert.That(roundTrip.ChangeKind, Is.EqualTo("updated")); Assert.That(roundTrip.Document, Is.Not.Null); Assert.That(roundTrip.Document!.RecordKey, Is.EqualTo("record-1")); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_ShardCatalogResponse() { var message = new CultNetShardCatalogResponseMessage { MessageId = "shards-1", Shards = [ new CultNetShardDescriptorMessage { ShardId = "players-eu", OwnerRuntimeId = "runtime-a", Epoch = 12, IsPrimary = true, SchemaIds = ["schema-player"], KeyPrefix = "player:", PrimaryEndpoints = ["cultnet://runtime-a:3075"], ReplicaEndpoints = ["cultnet://runtime-b:3075"], ReadReplicaEndpoints = ["cultnet://edge-1:3075"], Region = "eu-west" } ] }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetShardCatalogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("shards-1")); Assert.That(roundTrip.Shards, Has.Length.EqualTo(1)); Assert.That(roundTrip.Shards[0].ShardId, Is.EqualTo("players-eu")); Assert.That(roundTrip.Shards[0].Epoch, Is.EqualTo(12)); Assert.That(roundTrip.Shards[0].PrimaryEndpoints, Is.EqualTo(["cultnet://runtime-a:3075"])); Assert.That(roundTrip.Shards[0].Region, Is.EqualTo("eu-west")); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_ShardLogResponse() { var message = new CultNetShardLogResponseMessage { MessageId = "log-1", ShardId = "players-eu", ShardEpoch = 12, CompactedThrough = 40, Entries = [ new CultNetShardLogEntryMessage { Sequence = 42, CommittedAt = "2026-05-19T12:00:00.0000000Z", ChangeKind = "updated", Put = new CultNetDocumentPutRawMessage { MessageId = "put-42", ShardId = "players-eu", ShardEpoch = 12, Document = new CultNetRawDocumentRecord { SchemaId = "schema-player", RecordKey = "player:42", Payload = [0x91, 0x2A] } } } ] }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetShardLogResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("log-1")); Assert.That(roundTrip.ShardId, Is.EqualTo("players-eu")); Assert.That(roundTrip.ShardEpoch, Is.EqualTo(12)); Assert.That(roundTrip.CompactedThrough, Is.EqualTo(40)); Assert.That(roundTrip.Entries, Has.Length.EqualTo(1)); Assert.That(roundTrip.Entries[0].Sequence, Is.EqualTo(42)); Assert.That(roundTrip.Entries[0].Put, Is.Not.Null); 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_PeerExchangeResponse() { var message = new CultMeshPeerExchangeResponseMessage { MessageId = "pex-1", Peers = [ new CultMeshPeerCardMessage { PeerId = "peer-a", VerseId = "aetheria-main", Endpoints = ["cultnet://peer-a.example.test:3075"], Roles = [CultMeshPeerRoles.Discovery, CultMeshPeerRoles.ReadReplica], ShardIds = ["players"], Region = "eu-west", AuthorityLeaseId = "lease-1", ExpiresAt = "2026-05-20T12:00:00.0000000Z", Signature = "sig" } ] }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultMeshPeerExchangeResponseMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("pex-1")); Assert.That(roundTrip.Peers, Has.Length.EqualTo(1)); Assert.That(roundTrip.Peers[0].PeerId, Is.EqualTo("peer-a")); Assert.That(roundTrip.Peers[0].Roles, Does.Contain(CultMeshPeerRoles.ReadReplica)); Assert.That(roundTrip.Peers[0].AuthorityLeaseId, Is.EqualTo("lease-1")); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_SimulationObservation() { var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); var message = new CultNetSimulationObservationMessage { MessageId = "observation-1", Observation = new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash, ClaimSummary = "alice hit bob first", ObservedAt = "2026-05-19T12:00:00.0000000Z" } }; var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetSimulationObservationMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("observation-1")); Assert.That(roundTrip.Observation.WitnessRuntimeId, Is.EqualTo("watcher-1")); Assert.That(roundTrip.Observation.Frame, Is.EqualTo(100)); Assert.That(roundTrip.Observation.ClaimHash, Is.EqualTo(claimHash)); } [Test] public void CultNetSchemaMessageSerialization_RoundTrips_SimulationConsensusCandidate() { var candidate = new CultNetSimulationConsensusCandidate( "arena", 4, 100, "bob", "hit", "claim-hash", "alice hit bob first", witnessCount: 3, supportWeight: 3d, totalWeight: 4d, hasQuorum: true); var message = CultNetSimulationConsensusCandidateMessage.FromCandidate("candidate-1", candidate); var payload = CultNetSchemaMessageSerialization.Serialize(message); var roundTrip = (CultNetSimulationConsensusCandidateMessage)CultNetSchemaMessageSerialization.Deserialize(payload); Assert.That(roundTrip.MessageId, Is.EqualTo("candidate-1")); Assert.That(roundTrip.ShardId, Is.EqualTo("arena")); Assert.That(roundTrip.WitnessCount, Is.EqualTo(3)); Assert.That(roundTrip.SupportWeight, Is.EqualTo(3d)); Assert.That(roundTrip.TotalWeight, Is.EqualTo(4d)); Assert.That(roundTrip.Confidence, Is.EqualTo(0.75d)); Assert.That(roundTrip.HasQuorum, Is.True); } [Test] public async Task CultNetDocumentRegistry_RawSnapshotReplication_PreservesPayloadBytes() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var registry = new CultNetDocumentRegistry() .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var sourceEntry = new PlayerData { PlayerId = Guid.NewGuid(), Email = "cult@example.test", PasswordHash = "not-a-real-hash", Username = "CultGhost" }; var handle = await sourceCache.AddAsync(sourceEntry); var expectedPayload = SerializePlayerDataPayload(sourceEntry); var request = registry.CreateSnapshotRequest( "request-1", schemaIds: [sourceCache.Registry.GetRequired().SchemaId], recordKeys: [handle.Key.Value]); 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.GetByIndex("PlayerId", sourceEntry.PlayerId.ToString("D")); Assert.That(replicated, Is.Not.Null); Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); } // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's // pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter". [Test] public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry() .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" }); var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty()); var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request); Assert.That(response.Documents, Is.Empty); } // R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order // the caller sent them, not in schema-and-key sort order. This pins that order survives the // move to the shared evaluator. [Test] public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry() .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var players = new[] { "zeta", "alpha", "mid" } .Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name }) .ToArray(); var handles = new List>(); foreach (var player in players) handles.Add(await cache.AddAsync(player)); // Request in an order that matches neither the insertion order nor the ordinal-sorted // record-key order, so a schema-and-key sort would visibly reorder the response. var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value }; var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder); var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request); Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder)); } [Test] public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty() { Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null); Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Not.Null); Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Empty); Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null); Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty); Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" })); } [Test] public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard() { var cache = new CultCache(); var database = new CultNetDatabase(cache); var key = new CultRecordKey("player:watch"); var playerId = Guid.NewGuid(); var changes = new List>(); using var subscription = database .WatchByIndex("PlayerId", playerId.ToString("D")) .Subscribe(change => changes.Add(change)); var player = new PlayerData { PlayerId = playerId, Email = "watch@example.test", PasswordHash = "hash", Username = "Watcher" }; await database.PutAsync(key, player); var roundTrip = await database.GetAsync(key); Assert.That(roundTrip, Is.SameAs(player)); Assert.That(changes, Has.Count.EqualTo(1)); Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added)); Assert.That(changes[0].Key, Is.EqualTo(key)); Assert.That(changes[0].Shard.IsPrimary, Is.True); } [Test] public void CultNetDatabase_Rejects_LocalWrites_To_ReadOnlyShard() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ CultNetShardDescriptor.ReadOnly( "remote-players", "remote-runtime", schemaIds: [schemaId]) ] }); Assert.That( async () => await database.PutAsync( new CultRecordKey("player:remote"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "remote@example.test", PasswordHash = "hash", Username = "Remote" }), Throws.TypeOf() .With.Property(nameof(CultNetShardAuthorityException.Shard)) .Property(nameof(CultNetShardDescriptor.ShardId)) .EqualTo("remote-players")); } [Test] public async Task CultNetDatabase_Rejects_RawPut_With_StaleShardEpoch() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players", "runtime-a", epoch: 7, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://runtime-a:3075"]) ] }); var key = new CultRecordKey("player:stale"); var message = registry.CreateRawDocumentPutMessage( "put-stale", new CultRecordHandle(key), new PlayerData { PlayerId = Guid.NewGuid(), Email = "stale@example.test", PasswordHash = "hash", Username = "Stale" }); message.ShardId = "players"; message.ShardEpoch = 6; Assert.That( async () => await database.ApplyPutAsync(message), Throws.TypeOf() .With.Property(nameof(CultNetShardAuthorityException.Shard)) .Property(nameof(CultNetShardDescriptor.Epoch)) .EqualTo(7)); } [Test] public async Task CultNetDatabase_ApplyRawPut_Publishes_DomainChange() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); var key = new CultRecordKey("player:raw"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "raw@example.test", PasswordHash = "hash", Username = "Raw" }; var changes = new List>(); using var subscription = database.WatchRecord(key) .Subscribe(change => changes.Add(change)); var message = registry.CreateRawDocumentPutMessage( "put-raw", new CultRecordHandle(key), player); var applied = await database.ApplyPutAsync(message); Assert.That(applied, Is.TypeOf()); Assert.That(cache.Get(key), Is.Not.Null); Assert.That(changes, Has.Count.EqualTo(1)); Assert.That(changes[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added)); Assert.That(changes[0].Document, Is.Not.Null); Assert.That(changes[0].Document!.Username, Is.EqualTo("Raw")); } [Test] public async Task CultNetDatabase_ApplyRawPut_ResolvesForeignSchemaIdFromPayload() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var descriptor = cache.Registry.GetRequired(); var shard = new CultNetShardDescriptor( "network-notes", "runtime-a", epoch: 3, isPrimary: true, schemaIds: [descriptor.SchemaId], keyPrefix: "network-note:", primaryEndpoints: ["cultnet://runtime-a:3075"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); var key = new CultRecordKey("network-note:foreign-schema-put"); var changes = new List>(); using var subscription = database.WatchRecord(key) .Subscribe(change => changes.Add(change)); var message = new CultNetDocumentPutRawMessage { MessageId = "put-foreign-schema", ShardId = shard.ShardId, ShardEpoch = shard.Epoch, Document = new CultNetRawDocumentRecord { SchemaId = "runtime.generated.network-note.ui.42", RecordKey = key.Value, StoredAt = DateTimeOffset.UtcNow.ToString("O"), PayloadEncoding = "messagepack", Payload = CultDocumentMessagePackSerialization.SerializeUntyped(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "payload-routed", Revision = 5 }, typeof(NetworkSchemaNote)) } }; var applied = await database.ApplyPutAsync(message); Assert.That(applied, Is.TypeOf()); Assert.That(cache.Get(key)!.Text, Is.EqualTo("payload-routed")); Assert.That(changes, Has.Count.EqualTo(1)); Assert.That(changes[0].SchemaId, Is.EqualTo(descriptor.SchemaId)); Assert.That(changes[0].Document!.Revision, Is.EqualTo(5)); } [Test] public async Task CultNetDatabaseServer_Creates_Filtered_SnapshotResponse() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "snapshot@example.test", PasswordHash = "hash", Username = "Snapshot" }; var handle = await database.PutAsync(new CultRecordKey("player:snapshot"), player); var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest( "snapshot-request", recordKeys: [handle.Key.Value])); Assert.That(response.MessageId, Is.EqualTo("snapshot-request")); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].RecordKey, Is.EqualTo(handle.Key.Value)); Assert.That(response.Documents[0].Payload, Is.EqualTo(SerializePlayerDataPayload(player))); } [Test] public async Task CultNetDatabaseServer_Creates_SnapshotResponse_ForCompatibleSchemaAlias() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var key = new CultRecordKey("network-note:alias-snapshot"); var note = new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias snapshot", Revision = 7 }; await database.PutAsync(key, note); var response = databaseServer.CreateSnapshotResponse(registry.CreateSnapshotRequest( "snapshot-alias-request", schemaIds: ["tests.networking_note.v1"], recordKeys: [key.Value])); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value)); Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType()))); } [Test] public async Task CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var descriptor = cache.Registry.GetRequired(); var shard = new CultNetShardDescriptor( "network-notes", "runtime-a", epoch: 4, isPrimary: true, schemaIds: [descriptor.SchemaId], keyPrefix: "network-note:", primaryEndpoints: ["cultnet://runtime-a:3075"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); var key = new CultRecordKey("network-note:alias-shard-snapshot"); var note = new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias shard snapshot", Revision = 8 }; await database.PutAsync(key, note); var response = database.CreateShardSnapshotResponse( shard, "snapshot-shard-alias-request", registry.CreateSnapshotRequest( "snapshot-shard-alias-request", schemaIds: ["tests.networking_note.v1"], recordKeys: [key.Value])); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value)); Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType()))); } // S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): the shard snapshot answers // through the same evaluator as the non-shard path, no loop of its own. An unfiltered request // ({schemas:null, keys:null}) must return the same rows from both. [Test] public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var descriptor = cache.Registry.GetRequired(); var shard = new CultNetShardDescriptor( "unfiltered-notes", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [descriptor.SchemaId], keyPrefix: "unfiltered-note:", primaryEndpoints: ["cultnet://runtime-a:3080"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); await database.PutAsync(new CultRecordKey("unfiltered-note:one"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "one" }); await database.PutAsync(new CultRecordKey("unfiltered-note:two"), new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "two" }); // Outside the shard's key prefix (written to the cache directly: the database refuses a write no shard owns): must reach the non-shard snapshot but never the // shard-scoped one, so an unfiltered request still proves shard membership is enforced. await cache.UpsertAsync(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "outside" }, new CultRecordHandle(new CultRecordKey("other-note:outside-shard"))); var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-unfiltered", filter: null); var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-unfiltered", filter: null); Assert.That( shardScoped.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), Is.EqualTo(new[] { "unfiltered-note:one", "unfiltered-note:two" })); Assert.That( nonShard.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), Is.EqualTo(new[] { "other-note:outside-shard", "unfiltered-note:one", "unfiltered-note:two" })); Assert.That(shardScoped.Documents, Has.Length.EqualTo(2)); } // S2-3, second probe: an override of the binding schema id (rather than the descriptor's own) // must match on both the shard and non-shard paths - both run binding-alias expansion. [Test] public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnBindingSchemaIdOverride() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var descriptor = cache.Registry.GetRequired(); var shard = new CultNetShardDescriptor( "override-notes", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [descriptor.SchemaId], keyPrefix: "override-note:", primaryEndpoints: ["cultnet://runtime-a:3081"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); var key = new CultRecordKey("override-note:one"); await database.PutAsync(key, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "override" }); var request = registry.CreateSnapshotRequest( "snapshot-override-request", schemaIds: ["tests.networking_note.v1"], recordKeys: [key.Value]); var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-override", request); var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-override", request); Assert.That(nonShard.Documents, Has.Length.EqualTo(1)); Assert.That(shardScoped.Documents, Has.Length.EqualTo(1)); Assert.That(shardScoped.Documents[0].RecordKey, Is.EqualTo(nonShard.Documents[0].RecordKey)); } [Test] public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "wrong-network-notes", "runtime-b", epoch: 1, isPrimary: false, schemaIds: ["other.note.v1"], keyPrefix: "network-note:"), new CultNetShardDescriptor( "network-notes", "runtime-a", epoch: 2, isPrimary: true, schemaIds: ["tests.networking_note.v1"], keyPrefix: "network-note:") ] }); var key = new CultRecordKey("network-note:alias-routed-write"); await database.PutAsync(key, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "typed write routed by alias", Revision = 12 }); Assert.That(cache.Get(key)!.Text, Is.EqualTo("typed write routed by alias")); Assert.That(database.GetMutationLog("network-notes"), Has.Count.EqualTo(1)); Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty); } [Test] public async Task CultNetDatabase_Applies_Delete_BySchemaAlias() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "wrong-network-notes", "runtime-b", epoch: 1, isPrimary: false, schemaIds: ["other.note.v1"], keyPrefix: "network-note:"), new CultNetShardDescriptor( "network-notes", "runtime-a", epoch: 2, isPrimary: true, schemaIds: ["tests.networking_note.v1"], keyPrefix: "network-note:") ] }); var key = new CultRecordKey("network-note:alias-delete"); await database.PutAsync(key, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "delete through alias", Revision = 13 }); await database.ApplyDeleteAsync(new CultNetDocumentDeleteMessage { MessageId = "delete-alias", SchemaId = "tests.networking_note.v1", RecordKey = key.Value, ShardId = "network-notes", ShardEpoch = 2 }); Assert.That(cache.Get(key), Is.Null); Assert.That(database.GetMutationLog("network-notes").Last().Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed)); Assert.That(database.GetMutationLog("wrong-network-notes"), Is.Empty); } [Test] public async Task CultNetDatabase_Predicts_ClientOwnedInput_ThroughSchemaAliasScope() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { RuntimeId = "pilot-a", DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "wrong-network-inputs", "server", epoch: 1, isPrimary: false, schemaIds: ["other.note.v1"], keyPrefix: "network-input:"), new CultNetShardDescriptor( "network-inputs", "server", epoch: 1, isPrimary: false, schemaIds: ["tests.networking_note.v1"], keyPrefix: "network-input:") ], ClientAuthorityScopes = [ new CultNetClientAuthorityScope( "pilot-a", schemaIds: ["tests.networking_note.v1"], keyPrefix: "network-input:pilot-a") ] }); var key = new CultRecordKey("network-input:pilot-a:alias-prediction"); await database.PutPredictedAsync(key, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "predicted through alias scope", Revision = 1 }); Assert.That(cache.Get(key)!.Text, Is.EqualTo("predicted through alias scope")); } [Test] public void CultNetDatabaseServer_Creates_Filtered_ShardCatalogResponse() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "players", "runtime-a", epoch: 3, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://runtime-a:3075"], replicaEndpoints: ["cultnet://runtime-b:3075"], readReplicaEndpoints: ["cultnet://edge-a:3075"], region: "eu-west"), new CultNetShardDescriptor( "world", "runtime-c", epoch: 1, isPrimary: false, schemaIds: ["schema-world"], keyPrefix: "world:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var response = databaseServer.CreateShardCatalogResponse(new CultNetShardCatalogRequestMessage { MessageId = "catalog-players", SchemaIds = [schemaId], RecordKeys = ["player:one"] }); Assert.That(response.MessageId, Is.EqualTo("catalog-players")); Assert.That(response.Shards, Has.Length.EqualTo(1)); Assert.That(response.Shards[0].ShardId, Is.EqualTo("players")); Assert.That(response.Shards[0].Epoch, Is.EqualTo(3)); Assert.That(response.Shards[0].PrimaryEndpoints, Is.EqualTo(["cultnet://runtime-a:3075"])); Assert.That(response.Shards[0].ReadReplicaEndpoints, Is.EqualTo(["cultnet://edge-a:3075"])); var aliasResponse = databaseServer.CreateShardCatalogResponse(new CultNetShardCatalogRequestMessage { MessageId = "catalog-players-alias", SchemaIds = ["gamecult.player_data.v2"], RecordKeys = ["player:one"] }); Assert.That(aliasResponse.Shards, Has.Length.EqualTo(1)); Assert.That(aliasResponse.Shards[0].ShardId, Is.EqualTo("players")); } [Test] public async Task CultNetDatabaseServer_Forwards_NonPrimaryWrites_WhenConfigured() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players-remote", "runtime-owner", epoch: 9, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://runtime-owner:3075"]) ] }); var forwarder = new CapturingShardWriteForwarder(); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer( server, database, new CultNetDatabaseServerOptions { ForwardNonPrimaryWrites = true, WriteForwarder = forwarder }); var key = new CultRecordKey("player:forward"); var message = registry.CreateRawDocumentPutMessage( "put-forward", new CultRecordHandle(key), new PlayerData { PlayerId = Guid.NewGuid(), Email = "forward@example.test", PasswordHash = "hash", Username = "Forward" }); var exception = Assert.ThrowsAsync( async () => await database.ApplyPutAsync(message)); var forwarded = await databaseServer.TryForwardPutAsync(exception!, message); Assert.That(forwarded, Is.True); Assert.That(forwarder.PutCount, Is.EqualTo(1)); Assert.That(forwarder.LastPutShard!.ShardId, Is.EqualTo("players-remote")); Assert.That(forwarder.LastPutMessage!.ShardId, Is.EqualTo("players-remote")); Assert.That(forwarder.LastPutMessage.ShardEpoch, Is.EqualTo(9)); } [Test] public void CultNetSchemaWriteForwarder_Parses_CultNetEndpoints() { var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("cultnet://primary.example.test:4075"); Assert.That(parsed.Host, Is.EqualTo("primary.example.test")); Assert.That(parsed.Port, Is.EqualTo(4075)); } [Test] public void CultNetSchemaWriteForwarder_Uses_DefaultPort() { var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("cultnet://primary.example.test"); Assert.That(parsed.Host, Is.EqualTo("primary.example.test")); Assert.That(parsed.Port, Is.EqualTo(3075)); } [Test] public void CultNetSchemaWriteForwarder_Parses_RudpEndpoints() { var parsed = CultNetSchemaWriteForwarder.ParseEndpoint("rudp://primary.example.test:5075"); Assert.That(parsed.Host, Is.EqualTo("primary.example.test")); Assert.That(parsed.Port, Is.EqualTo(5075)); } [Test] public void CultNetSchemaWriteForwarder_Rejects_InvalidEndpoint() { Assert.That( () => CultNetSchemaWriteForwarder.ParseEndpoint("http://primary.example.test:3075"), Throws.TypeOf()); } [Test] public void CultNetSchemaClients_CreateForEndpoint_Selects_RudpClient() { using var rudp = CultNetSchemaClients.CreateForEndpoint("rudp://127.0.0.1:5075"); using var liteNetLib = CultNetSchemaClients.CreateForEndpoint( "cultnet://127.0.0.1:3075", DevelopmentClientSecurity); Assert.That(rudp, Is.TypeOf()); Assert.That(liteNetLib, Is.TypeOf()); } [Test] public async Task RudpCultNetSchemaClient_Carries_SchemaMessages() { using var serverSocket = BindUdpSocket(); using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-schema-server", Mode = CultNetRudpSocketMode.Server, Socket = serverSocket, ConnectionId = 0x43554c54, InitialSequence = 100, TransportId = "schema-rudp", ResendDelayMs = 25, MaxFragmentBytes = 1024 }); var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var serverThread = new Thread(() => { try { while (!serverDone.Task.IsCompleted) { var request = server.ReceiveSchemaMessageOnce(); server.PollResends(); if (request == null) { Thread.Sleep(5); continue; } server.SendSchemaMessage(new CultNetSchemaCatalogResponseMessage { MessageId = request.MessageId, Schemas = [ new CultNetSchemaDescriptor { SchemaId = "rudp.schema.test", Kind = "wire_message", SchemaVersion = "rudp.schema.test.v0", WireContracts = ["cultnet.schema.v0"] } ] }); serverDone.TrySetResult(); return; } } catch (Exception error) { serverDone.TrySetException(error); } }) { IsBackground = true }; serverThread.Start(); using var client = CultNetSchemaClients.CreateRudp(runtimeId: "csharp-rudp-schema-client"); var responseCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(response => responseCompletion.TrySetResult(response)); client.Connect("127.0.0.1", ((IPEndPoint)serverSocket.LocalEndPoint!).Port); await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); client.SendCultNet(new CultNetSchemaCatalogRequestMessage { MessageId = "rudp-schema-client-test", IncludeSchemaJson = false }); var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-client-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.test")); } [Test] public async Task CultMeshVerseDiscoveryClient_Fetches_Over_RudpEndpoint() { using var serverSocket = BindUdpSocket(); using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-verse-discovery-server", Mode = CultNetRudpSocketMode.Server, Socket = serverSocket, ConnectionId = 0x43554c54, InitialSequence = 100, TransportId = "schema-rudp", ResendDelayMs = 25, MaxFragmentBytes = 1024 }); var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var serverThread = new Thread(() => { try { while (!serverDone.Task.IsCompleted) { var request = server.ReceiveSchemaMessageOnce(); server.PollResends(); if (request == null) { Thread.Sleep(5); continue; } server.SendSchemaMessage(new CultMeshVerseCatalogResponseMessage { MessageId = request.MessageId, Verses = [ new CultMeshVerseDescriptor( "rudp-verse", "RUDP Verse", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), discoveryEndpoints: ["rudp://127.0.0.1"]).ToMessage() ] }); serverDone.TrySetResult(); return; } } catch (Exception error) { serverDone.TrySetException(error); } }) { IsBackground = true }; serverThread.Start(); var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions { ConnectTimeout = TimeSpan.FromSeconds(2), ResponseTimeout = TimeSpan.FromSeconds(2) }); var response = await client.FetchAsync($"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}"); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); serverThread.Join(); Assert.That(response.Verses.Single().VerseId, Is.EqualTo("rudp-verse")); } [Test] public async Task CultMeshPeerExchangeClient_Fetches_Over_RudpEndpoint() { using var serverSocket = BindUdpSocket(); using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = "csharp-rudp-peer-exchange-server", Mode = CultNetRudpSocketMode.Server, Socket = serverSocket, ConnectionId = 0x43554c54, InitialSequence = 100, TransportId = "schema-rudp", ResendDelayMs = 25, MaxFragmentBytes = 1024 }); var serverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var serverThread = new Thread(() => { try { while (!serverDone.Task.IsCompleted) { var request = server.ReceiveSchemaMessageOnce(); server.PollResends(); if (request == null) { Thread.Sleep(5); continue; } server.SendSchemaMessage(new CultMeshPeerExchangeResponseMessage { MessageId = request.MessageId, Peers = [ new CultMeshPeerCard( "rudp-peer", request.VerseId, ["rudp://127.0.0.1"], roles: ["mesh-peer"]).ToMessage() ] }); serverDone.TrySetResult(); return; } } catch (Exception error) { serverDone.TrySetException(error); } }) { IsBackground = true }; serverThread.Start(); var client = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions { ConnectTimeout = TimeSpan.FromSeconds(2), ResponseTimeout = TimeSpan.FromSeconds(2) }); var response = await client.FetchAsync( $"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}", new CultMeshPeerExchangeRequestMessage { VerseId = "local" }); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); serverThread.Join(); Assert.That(response.Peers.Single().PeerId, Is.EqualTo("rudp-peer")); } [Test] public void CultMesh_CreateClient_Returns_CultNetClient() { using var client = CultMesh.CreateClient(DevelopmentClientSecurity); Assert.That(client, Is.Not.Null); Assert.That(client.Connected, Is.False); } [Test] public void CultMesh_CreateVerseDiscoveryClient_Returns_DiscoveryClient() { var client = CultMesh.CreateVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions { ConnectTimeout = TimeSpan.FromMilliseconds(250), ResponseTimeout = TimeSpan.FromMilliseconds(250) }); Assert.That(client, Is.Not.Null); } [Test] public void CultMesh_CreatePeerExchangeClient_Returns_ExchangeClient() { var client = CultMesh.CreatePeerExchangeClient(new CultMeshPeerExchangeClientOptions { ConnectTimeout = TimeSpan.FromMilliseconds(250), ResponseTimeout = TimeSpan.FromMilliseconds(250) }); Assert.That(client, Is.Not.Null); } [Test] public async Task CultMeshVerseDiscoveryClient_Uses_SchemaClientPort() { var fake = new CapturingSchemaClient(message => { var request = (CultMeshVerseCatalogRequestMessage)message; return new CultMeshVerseCatalogResponseMessage { MessageId = request.MessageId, Verses = [ new CultMeshVerseDescriptor( "local", "Local Verse", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), discoveryEndpoints: ["cultnet://mesh.example.test:4075"]).ToMessage() ] }; }); var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions { CreateClient = () => fake }); var response = await client.FetchAsync("cultnet://mesh.example.test:4075"); Assert.That(fake.ConnectedHost, Is.EqualTo("mesh.example.test")); Assert.That(fake.ConnectedPort, Is.EqualTo(4075)); Assert.That(fake.SentMessages.Single(), Is.TypeOf()); Assert.That(response.Verses.Single().VerseId, Is.EqualTo("local")); } [Test] public async Task CultMeshVerseDiscoveryClient_ReportsDeterministicConnectionFailureTimeline() { var clock = new ManualCultMeshClock(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero)); var diagnostics = new CultMeshDiagnosticBuffer(); var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new NeverConnectedSchemaClient(), Clock = clock, Diagnostics = diagnostics, SourceId = "test-bootstrap", ConnectTimeout = TimeSpan.FromMilliseconds(50) }); var error = Assert.ThrowsAsync(() => client.FetchAsync("rudp://unavailable.example.test:4075")); Assert.That(error!.Message, Does.Contain("unavailable.example.test")); var timeline = diagnostics.Snapshot(); Assert.That(timeline.Select(item => item.Kind), Is.EqualTo(new[] { CultMeshDiagnosticKind.ConnectionAttempt, CultMeshDiagnosticKind.CandidateRejected })); Assert.That(timeline[0].ObservedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero))); Assert.That(timeline[1].ObservedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 7, 12, 18, 0, 0, 50, TimeSpan.Zero))); Assert.That(timeline[1].State, Is.EqualTo("unavailable")); Assert.That(timeline[1].ReasonCode, Is.EqualTo("timeout")); Assert.That(timeline[1].SourceId, Is.EqualTo("test-bootstrap")); Assert.That(timeline[1].LibraryVersion, Is.Not.Empty); } [Test] public async Task CultMeshVerseDiscoveryClient_CompatibilityDiscoveryKeepsGoodConcurrentSource() { var clients = new ConcurrentQueue(); clients.Enqueue(new NeverConnectedSchemaClient()); clients.Enqueue(new CapturingSchemaClient(message => { var request = (CultMeshVerseCatalogRequestMessage)message; return new CultMeshVerseCatalogResponseMessage { MessageId = request.MessageId, Verses = [new CultMeshVerseDescriptor( "aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), discoveryEndpoints: ["rudp://aetheria:3076"]).ToMessage()] }; })); var client = new CultMeshVerseDiscoveryClient(new CultMeshVerseDiscoveryClientOptions { CreateClient = () => clients.TryDequeue(out var next) ? next : throw new InvalidOperationException("Unexpected client."), Clock = new ManualCultMeshClock(new DateTimeOffset(2026, 7, 12, 18, 0, 0, TimeSpan.Zero)), ConnectTimeout = TimeSpan.FromMilliseconds(50) }); using var catalog = new CultMeshVerseCatalog(); var count = await client.DiscoverAsync( catalog, new[] { "rudp://dead.example.test:4075", "rudp://good.example.test:4075" }); Assert.That(count, Is.EqualTo(1)); Assert.That(catalog.Verses.Single().VerseId, Is.EqualTo("aetheria")); } [Test] public async Task CultMeshPeerExchangeClient_Uses_SchemaClientPort() { var fake = new CapturingSchemaClient(message => { var request = (CultMeshPeerExchangeRequestMessage)message; return new CultMeshPeerExchangeResponseMessage { MessageId = request.MessageId, Peers = [ new CultMeshPeerCard( "peer-a", request.VerseId, ["cultnet://peer-a.example.test:3075"], roles: ["shard-primary"]).ToMessage() ] }; }); var client = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions { CreateClient = () => fake }); var response = await client.FetchAsync( "cultnet://mesh.example.test:4075", new CultMeshPeerExchangeRequestMessage { VerseId = "local" }); Assert.That(fake.ConnectedHost, Is.EqualTo("mesh.example.test")); Assert.That(fake.SentMessages.Single(), Is.TypeOf()); Assert.That(response.Peers.Single().PeerId, Is.EqualTo("peer-a")); } [Test] public async Task CultNetShardFetchers_Use_SchemaClientPort() { var logClient = new CapturingSchemaClient(message => { var request = (CultNetShardLogRequestMessage)message; return new CultNetShardLogResponseMessage { MessageId = request.MessageId, ShardId = request.ShardId, ShardEpoch = request.ShardEpoch ?? 0, Entries = [] }; }); var snapshotClient = new CapturingSchemaClient(message => { var request = (CultNetSnapshotRequestMessage)message; return new CultNetSnapshotResponseRawMessage { MessageId = request.MessageId, ShardId = request.ShardId, ShardEpoch = request.ShardEpoch, Documents = [] }; }); var shard = new CultNetShardDescriptor( "notes", "primary", 7, isPrimary: false, schemaIds: ["note.v0"], primaryEndpoints: ["cultnet://primary.example.test:4075"]); var logFetcher = new CultNetSchemaShardLogFetcher(new CultNetSchemaShardLogFetcherOptions { CreateClient = () => logClient }); var snapshotFetcher = new CultNetSchemaShardSnapshotFetcher(new CultNetSchemaShardSnapshotFetcherOptions { CreateClient = () => snapshotClient }); var log = await logFetcher.FetchAsync(shard, afterSequence: 4, limit: 2); var snapshot = await snapshotFetcher.FetchAsync(shard); Assert.That(logClient.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(logClient.SentMessages.Single(), Is.TypeOf()); Assert.That(log.ShardId, Is.EqualTo("notes")); Assert.That(snapshotClient.SentMessages.Single(), Is.TypeOf()); Assert.That(snapshot.ShardEpoch, Is.EqualTo(7)); } [Test] public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort() { var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake }); var shard = new CultNetShardDescriptor( "notes", "primary", 3, isPrimary: false, primaryEndpoints: ["cultnet://primary.example.test:4075"]); await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }); var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single(); Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(sent.ShardId, Is.EqualTo("notes")); Assert.That(sent.ShardEpoch, Is.EqualTo(3)); } [Test] public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets() { var vanillaRules = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "rules:v1", "vanilla"); var moddedRules = CultMeshVerseDescriptor.ComputeRulesHash("aetheria", "rules:v1", "skylands"); var aetheria = new CultMeshVerseDescriptor( "aetheria-main", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", vanillaRules), authorityRoutes: [ new CultMeshAuthorityRoute( "gc-us-east", "cultmesh://us-east.aetheria.example.test:3075", [CultMeshProtocols.Documents.Value], generation: "east-1"), new CultMeshAuthorityRoute( "gc-eu-west", "cultmesh://eu-west.aetheria.example.test:3075", [CultMeshProtocols.Documents.Value], generation: "west-1") ]); var modded = new CultMeshVerseDescriptor( "aetheria-skylands", "Aetheria: Skylands", CultMeshVerseAuthorityModel.SubscribedOverlay, new CultMeshVerseCompatibility( "cultmesh.v0", moddedRules, compatibleVerseIds: ["aetheria-main"], requiredPluginIds: ["skylands"]), parentVerseId: "aetheria-main"); var incompatible = new CultMeshVerseDescriptor( "other-game", "Other Game", CultMeshVerseAuthorityModel.PeerToPeer, new CultMeshVerseCompatibility("cultmesh.v0", CultMeshVerseDescriptor.ComputeRulesHash("other"))); using var catalog = CultMesh.CreateVerseCatalog(); var updates = new List(); using var subscription = catalog.Watch().Subscribe(update => updates.Add(update)); catalog.Upsert(aetheria); catalog.Upsert(modded); catalog.Upsert(incompatible); var targets = catalog.FindTransferTargets(aetheria); Assert.That(updates, Has.Count.EqualTo(3)); Assert.That(targets, Has.Count.EqualTo(1)); Assert.That(targets[0].VerseId, Is.EqualTo("aetheria-skylands")); Assert.That(modded.CanTransferFrom(aetheria), Is.True); 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 void CultMeshVerseCatalog_Upserts_WireDiscoveryResponse() { using var catalog = CultMesh.CreateVerseCatalog(); var updates = new List(); using var subscription = catalog.Watch().Subscribe(updates.Add); catalog.Upsert(new CultMeshVerseCatalogResponseMessage { MessageId = "verses-apply", Verses = [ new CultMeshVerseDescriptorMessage { VerseId = "aetheria-branch", DisplayName = "Aetheria Branch", AuthorityModel = nameof(CultMeshVerseAuthorityModel.SubscribedOverlay), Compatibility = new CultMeshVerseCompatibilityMessage { TransportVersion = "cultmesh.v0", RulesHash = "branch-rules", CompatibleVerseIds = ["aetheria-main"] }, ParentVerseId = "aetheria-main" } ] }); var verse = catalog.Get("aetheria-branch"); Assert.That(verse, Is.Not.Null); Assert.That(verse!.AuthorityModel, Is.EqualTo(CultMeshVerseAuthorityModel.SubscribedOverlay)); Assert.That(verse.ParentVerseId, Is.EqualTo("aetheria-main")); Assert.That(updates, Has.Count.EqualTo(1)); } [Test] public void CultMeshPeerExchangeServer_Creates_FilteredPeerResponse() { using var catalog = CultMesh.CreatePeerCatalog(); catalog.Upsert(new CultMeshPeerCard( "peer-primary", "aetheria-main", ["cultnet://primary.example.test:3075"], roles: [CultMeshPeerRoles.ShardPrimary], shardIds: ["players"], region: "us-east", authorityLeaseId: "lease-primary")); catalog.Upsert(new CultMeshPeerCard( "peer-read", "aetheria-main", ["cultnet://read.example.test:3075"], roles: [CultMeshPeerRoles.ReadReplica], shardIds: ["players"], region: "eu-west")); using var server = new Server(new CultCache(), DevelopmentServerSecurity); using var exchange = new CultMeshPeerExchangeServer(server, catalog); var response = exchange.CreateResponse(new CultMeshPeerExchangeRequestMessage { MessageId = "pex-filter", VerseId = "aetheria-main", Roles = [CultMeshPeerRoles.ReadReplica], KnownPeerIds = ["already-known"] }); Assert.That(response.MessageId, Is.EqualTo("pex-filter")); Assert.That(response.Peers, Has.Length.EqualTo(1)); Assert.That(response.Peers[0].PeerId, Is.EqualTo("peer-read")); Assert.That(response.Peers[0].Roles, Does.Contain(CultMeshPeerRoles.ReadReplica)); } [Test] public void CultMeshPeerCatalog_Upserts_WirePeerExchangeResponse() { using var catalog = CultMesh.CreatePeerCatalog(); var updates = new List(); using var subscription = catalog.Watch().Subscribe(updates.Add); catalog.Upsert(new CultMeshPeerExchangeResponseMessage { MessageId = "pex-apply", Peers = [ new CultMeshPeerCardMessage { PeerId = "peer-observer", VerseId = "aetheria-main", Endpoints = ["cultnet://observer.example.test:3075"], Roles = [CultMeshPeerRoles.SimulationObserver], ShardIds = ["arena"] } ] }); var peers = catalog.Find("aetheria-main", CultMeshPeerRoles.SimulationObserver); Assert.That(peers, Has.Count.EqualTo(1)); Assert.That(peers[0].PeerId, Is.EqualTo("peer-observer")); Assert.That(updates, Has.Count.EqualTo(1)); } [Test] public void CultMeshAuthorityResolver_Authorizes_PeerRoleAndShard() { var now = DateTimeOffset.Parse("2026-05-20T12:00:00.0000000Z"); var catalog = CultMesh.CreateAuthorityLeaseCatalog(); var resolver = new CultMeshAuthorityResolver( catalog, new AcceptingAuthoritySignatureVerifier(), new NoAuthorityRevocations(), new ManualCultMeshClock(now)); using var peers = CultMesh.CreatePeerCatalog(); var peer = new CultMeshPeerCard( "peer-primary", "aetheria-main", ["cultnet://primary.example.test:3075"], roles: [CultMeshPeerRoles.ShardPrimary], shardIds: ["players-us-east"], authorityLeaseId: "lease-primary"); peers.Upsert(peer); Assert.That(peers.FindAuthorized( "aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east"), Is.Empty); catalog.Upsert(new CultMeshAuthorityLease( "lease-primary", "aetheria-main", "peer-primary", [CultMeshPeerRoles.ShardPrimary], ["players-us-east"], "gc-operator", now.AddMinutes(-5), now.AddMinutes(5), signature: "sig", authorityEpoch: 1)); Assert.That(resolver.Resolve(new CultMeshAuthorityRequest( peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", 1)).IsAuthorized, Is.True); Assert.That(peers.FindAuthorized( "aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east").Single(), Is.SameAs(peer)); Assert.That(peers.FirstAuthorized( "aetheria-main", CultMeshPeerRoles.ShardPrimary, resolver, 1, "players-us-east"), Is.SameAs(peer)); Assert.That(peers.FirstAuthorized( "aetheria-main", CultMeshPeerRoles.ReadReplica, resolver, 1, "players-us-east"), Is.Null); Assert.That(resolver.Resolve(new CultMeshAuthorityRequest( peer, CultMeshPeerRoles.ShardPrimary, "players-eu", 1)).IsAuthorized, Is.False); Assert.That(resolver.Resolve(new CultMeshAuthorityRequest( peer, CultMeshPeerRoles.ReadReplica, "players-us-east", 1)).IsAuthorized, Is.False); #pragma warning disable CS0618 Assert.That(catalog.IsAuthorized(peer, CultMeshPeerRoles.ShardPrimary, "players-us-east", now), Is.False); #pragma warning restore CS0618 } private static string? RudpClientBoundHost(string endpoint, string? bindHost = null) { using var client = CultMesh.CreateRudpClient( "csharp-cultmesh-bind-client", 0x10203060u, endpoint, new CultMeshRudpSocketOptions { BindHost = bindHost }); return client.Profile.Transports[0].Host; } // A client bound to loopback cannot send to another host on Windows, so a client for a // remote endpoint binds the unspecified address of its family. [Test] public void CultMeshRudpClient_ForARemoteEndpoint_BindsTheUnspecifiedAddress() { Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872"), Is.EqualTo("0.0.0.0")); Assert.That(RudpClientBoundHost("rudp://[2001:db8::1]:17872"), Is.EqualTo("::")); } [Test] public void CultMeshRudpClient_ForALoopbackEndpoint_BindsLoopback() { Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872"), Is.EqualTo("127.0.0.1")); Assert.That(RudpClientBoundHost("rudp://[::1]:17872"), Is.EqualTo("::1")); } [Test] public void CultMeshRudpClient_BindsAnExplicitBindHostWhateverTheEndpoint() { Assert.That(RudpClientBoundHost("rudp://10.77.0.1:17872", "127.0.0.1"), Is.EqualTo("127.0.0.1")); Assert.That(RudpClientBoundHost("rudp://127.0.0.1:17872", "0.0.0.0"), Is.EqualTo("0.0.0.0")); } [Test] public void CultMeshRudpServer_BindsEveryIPv4InterfaceUnlessToldOtherwise() { using var anywhere = CultMesh.CreateRudpServer("csharp-cultmesh-bind-server", 0x10203061u); using var loopback = CultMesh.CreateRudpServer( "csharp-cultmesh-bind-server", 0x10203062u, new CultMeshRudpSocketOptions { BindHost = "127.0.0.1" }); Assert.That(anywhere.Profile.Transports[0].Host, Is.EqualTo("0.0.0.0")); Assert.That(loopback.Profile.Transports[0].Host, Is.EqualTo("127.0.0.1")); } [Test] public void CultMeshRudpFacade_CreatesAuthorizedClientFromPeerEndpoint() { var connectionId = 0x10203045u; using var server = CultMesh.CreateRudpServer( "csharp-cultmesh-rudp-server", connectionId, new CultMeshRudpSocketOptions { InitialSequence = 100, ResendDelayMs = 25, MaxFragmentBytes = 1024, MaxPendingReliablePackets = 16 }); var serverPort = server.Profile.Transports[0].Port; Assert.That(serverPort, Is.Not.Null); var endpoint = CultMesh.ParseRudpEndpoint($"rudp://127.0.0.1:{serverPort}"); Assert.That(endpoint.Host, Is.EqualTo("127.0.0.1")); Assert.That(endpoint.Port, Is.EqualTo(serverPort)); using var peers = CultMesh.CreatePeerCatalog(); var leases = CultMesh.CreateAuthorityLeaseCatalog(); var now = DateTimeOffset.UtcNow; var authority = new CultMeshAuthorityResolver( leases, new AcceptingAuthoritySignatureVerifier(), new NoAuthorityRevocations(), new ManualCultMeshClock(now)); var peer = new CultMeshPeerCard( "csharp-cultmesh-rudp-server", "local", [endpoint.Uri], roles: ["schema"], authorityLeaseId: "lease:csharp-cultmesh-rudp-server"); peers.Upsert(peer); Assert.Throws(() => CultMesh.CreateRudpClientForAuthorizedPeer( "csharp-cultmesh-rudp-client", connectionId, peers, authority, 1, "local", "schema", options: new CultMeshRudpSocketOptions { ResendDelayMs = 25, MaxFragmentBytes = 1024, MaxPendingReliablePackets = 16 })); leases.Upsert(new CultMeshAuthorityLease( "lease:csharp-cultmesh-rudp-server", "local", "csharp-cultmesh-rudp-server", ["schema"], [], "csharp-authority", now.AddSeconds(-1), now.AddSeconds(30), signature: "sig", authorityEpoch: 1)); 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, authority, 1, "local", "schema", options: new CultMeshRudpClientOptions { 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); Assert.That(client.Connected, Is.True); Assert.That(server.Connected, Is.True); 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")); } [Test] public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-node", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); var cachePath = Path.Combine(rootPath, "world.ccmp"); var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var options = new CultMeshNodeOptions { StartServer = false, EnableDurableShardLogs = true, DatabaseOptions = new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players-mesh-default-log", "mesh-runtime", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] } }; var key = new CultRecordKey("player:mesh-log"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "mesh-log@example.test", PasswordHash = "hash", Username = "MeshLog" }; using (var node = await CultMesh.CreateNodeAsync(cachePath, options)) { await node.Database.PutAsync(key, player); } using var restarted = await CultMesh.CreateNodeAsync(cachePath, options); var response = restarted.DatabaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-mesh-default-log" }); Assert.That(response.Entries, Has.Length.EqualTo(1)); Assert.That(response.Entries[0].Put, Is.Not.Null); Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo(key.Value)); Assert.That(Directory.Exists(Path.Combine(rootPath, "world.cultmesh", "shard-logs")), Is.True); } [Test] public async Task CultMeshNode_CanRoundTrip_DurableTypedDocument_Through_PublicDatabaseSurface() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-quickstart", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); var cachePath = Path.Combine(rootPath, "world.ccmp"); var documents = new CultNetDocumentRegistry() .Register(CultNetDocumentBinding.ForDocument()); var key = new CultRecordKey("note:intro"); using (var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions { StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "quickstart-local", DocumentRegistry = documents } })) { await node.Database.PutAsync(key, new MeshQuickstartNote { NoteId = key.Value, Body = "hello from a durable CultMesh node" }); var live = await node.Database.GetAsync(key); Assert.That(node.Store, Is.Not.Null); Assert.That(live, Is.Not.Null); Assert.That(live!.Body, Is.EqualTo("hello from a durable CultMesh node")); await node.FlushAsync(); } using var reopened = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions { StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "quickstart-local", DocumentRegistry = documents } }); var stored = await reopened.Database.GetAsync(key); Assert.That(stored, Is.Not.Null); Assert.That(stored!.NoteId, Is.EqualTo(key.Value)); Assert.That(stored.Body, Is.EqualTo("hello from a durable CultMesh node")); } [Test] public async Task CultNetDatabase_Appends_PerShardMutationLog() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "players-log", "runtime-a", epoch: 4, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); var key = new CultRecordKey("player:log"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "log@example.test", PasswordHash = "hash", Username = "Log" }; await database.PutAsync(key, player); player.Username = "LogUpdated"; await database.PutAsync(key, player); await database.DeleteAsync(key); var entries = database.GetMutationLog("players-log"); Assert.That(entries, Has.Count.EqualTo(3)); Assert.That(new[] { entries[0].Sequence, entries[1].Sequence, entries[2].Sequence }, Is.EqualTo([1, 2, 3])); Assert.That(entries[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added)); Assert.That(entries[1].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Updated)); Assert.That(entries[2].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Removed)); Assert.That(entries[0].ShardEpoch, Is.EqualTo(4)); } [Test] public async Task CultNetDatabase_MutationLog_CanCatchUpAfterSequence() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "players-catchup", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); await database.PutAsync( new CultRecordKey("player:one"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "one@example.test", PasswordHash = "hash", Username = "One" }); await database.PutAsync( new CultRecordKey("player:two"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "two@example.test", PasswordHash = "hash", Username = "Two" }); var entries = database.GetMutationLog("players-catchup", afterSequence: 1); Assert.That(entries, Has.Count.EqualTo(1)); Assert.That(entries[0].Sequence, Is.EqualTo(2)); Assert.That(entries[0].Key.Value, Is.EqualTo("player:two")); } [Test] public async Task CultNetDatabaseServer_Creates_ShardLogResponse_AfterSequence() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players-wire-log", "runtime-a", epoch: 5, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var first = new PlayerData { PlayerId = Guid.NewGuid(), Email = "one@example.test", PasswordHash = "hash", Username = "One" }; var second = new PlayerData { PlayerId = Guid.NewGuid(), Email = "two@example.test", PasswordHash = "hash", Username = "Two" }; await database.PutAsync(new CultRecordKey("player:one"), first); await database.PutAsync(new CultRecordKey("player:two"), second); var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { MessageId = "wire-log", ShardId = "players-wire-log", ShardEpoch = 5, AfterSequence = 1 }); Assert.That(response.MessageId, Is.EqualTo("wire-log")); Assert.That(response.ShardId, Is.EqualTo("players-wire-log")); Assert.That(response.ShardEpoch, Is.EqualTo(5)); Assert.That(response.ResyncRequired, Is.False); Assert.That(response.Entries, Has.Length.EqualTo(1)); Assert.That(response.Entries[0].Sequence, Is.EqualTo(2)); Assert.That(response.Entries[0].ChangeKind, Is.EqualTo("added")); Assert.That(response.Entries[0].Put, Is.Not.Null); Assert.That(response.Entries[0].Put!.ShardId, Is.EqualTo("players-wire-log")); Assert.That(response.Entries[0].Put!.ShardEpoch, Is.EqualTo(5)); Assert.That(response.Entries[0].Put!.Document.Payload, Is.EqualTo(SerializePlayerDataPayload(second))); } [Test] public async Task CultNetDatabaseServer_Creates_ShardLogDeleteEntry() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players-wire-delete", "runtime-a", epoch: 6, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var key = new CultRecordKey("player:delete-log"); await database.PutAsync( key, new PlayerData { PlayerId = Guid.NewGuid(), Email = "delete@example.test", PasswordHash = "hash", Username = "Delete" }); await database.DeleteAsync(key); var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-wire-delete", AfterSequence = 1 }); Assert.That(response.Entries, Has.Length.EqualTo(1)); Assert.That(response.Entries[0].Sequence, Is.EqualTo(2)); Assert.That(response.Entries[0].ChangeKind, Is.EqualTo("removed")); Assert.That(response.Entries[0].Put, Is.Null); Assert.That(response.Entries[0].Delete, Is.Not.Null); Assert.That(response.Entries[0].Delete!.SchemaId, Is.EqualTo(schemaId)); Assert.That(response.Entries[0].Delete!.RecordKey, Is.EqualTo(key.Value)); Assert.That(response.Entries[0].Delete!.ShardEpoch, Is.EqualTo(6)); } [Test] public async Task CultNetDatabaseServer_Reads_DurableShardLog_AfterRestart() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N")); var schemaId = new CultCache().Registry.GetRequired().SchemaId; var shard = new CultNetShardDescriptor( "players-durable-log", "runtime-a", epoch: 7, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:"); var sourceCache = new CultCache(); var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument( sourceCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions { DocumentRegistry = sourceRegistry, MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), Shards = [shard] }); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "durable@example.test", PasswordHash = "hash", Username = "Durable" }; await sourceDatabase.PutAsync(new CultRecordKey("player:durable"), player); sourceDatabase.Dispose(); var restartedCache = new CultCache(); var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions { MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), Shards = [shard] }); using var server = new Server(restartedCache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, restartedDatabase); var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { MessageId = "durable-log", ShardId = "players-durable-log", ShardEpoch = 7 }); Assert.That(response.ResyncRequired, Is.False); Assert.That(response.Entries, Has.Length.EqualTo(1)); Assert.That(response.Entries[0].Sequence, Is.EqualTo(1)); Assert.That(response.Entries[0].Put, Is.Not.Null); Assert.That(response.Entries[0].Put!.Document.SchemaId, Is.EqualTo(schemaId)); Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo("player:durable")); Assert.That(response.Entries[0].Put!.Document.Payload, Is.EqualTo(SerializePlayerDataPayload(player))); } [Test] public async Task CultNetDatabaseServer_RequiresResync_ForCompactedShardLogHistory() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N")); var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var store = new CultNetFileShardMutationLogStore(rootPath); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, MutationLogStore = store, Shards = [ new CultNetShardDescriptor( "players-compacted-log", "runtime-a", epoch: 8, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); await database.PutAsync( new CultRecordKey("player:first"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "first@example.test", PasswordHash = "hash", Username = "First" }); await database.PutAsync( new CultRecordKey("player:second"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "second@example.test", PasswordHash = "hash", Username = "Second" }); store.CompactThrough("players-compacted-log", 1); var staleResponse = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-compacted-log", ShardEpoch = 8, AfterSequence = 0 }); var currentResponse = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-compacted-log", ShardEpoch = 8, AfterSequence = 1 }); Assert.That(staleResponse.ResyncRequired, Is.True); Assert.That(staleResponse.Reason, Is.EqualTo("compacted_history")); Assert.That(staleResponse.CompactedThrough, Is.EqualTo(1)); Assert.That(staleResponse.Entries, Is.Empty); Assert.That(currentResponse.ResyncRequired, Is.False); Assert.That(currentResponse.Entries, Has.Length.EqualTo(1)); Assert.That(currentResponse.Entries[0].Sequence, Is.EqualTo(2)); } [Test] public async Task CultNetDatabase_Applies_ShardLogResponse_ToReplica() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument( sourceCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument( targetCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = sourceCache.Registry.GetRequired().SchemaId; var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions { DocumentRegistry = sourceRegistry, Shards = [ new CultNetShardDescriptor( "players-replica", "runtime-a", epoch: 10, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:") ] }); var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions { DocumentRegistry = targetRegistry, Shards = [ new CultNetShardDescriptor( "players-replica", "runtime-a", epoch: 10, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:") ] }); using var server = new Server(sourceCache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase); var key = new CultRecordKey("player:replica"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "replica@example.test", PasswordHash = "hash", Username = "Replica" }; await sourceDatabase.PutAsync(key, player); var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-replica", ShardEpoch = 10 }); var sequence = await targetDatabase.ApplyShardLogResponseAsync(response); var replicated = targetCache.Get(key); var replayedSequence = await targetDatabase.ApplyShardLogResponseAsync(response); Assert.That(sequence, Is.EqualTo(1)); Assert.That(replayedSequence, Is.EqualTo(1)); Assert.That(targetDatabase.GetAppliedShardSequence("players-replica"), Is.EqualTo(1)); Assert.That(replicated, Is.Not.Null); Assert.That(replicated!.Username, Is.EqualTo("Replica")); Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1)); // R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too - // it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the // ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and // cursor treat a replicated row as never written. Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); } [Test] public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument( sourceCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry) .Register(CultNetDocumentBinding.ForDocument( targetCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = sourceCache.Registry.GetRequired().SchemaId; CultNetShardDescriptor Shard(bool isPrimary) => new( "players-replica-delete", "runtime-a", epoch: 1, isPrimary: isPrimary, schemaIds: [schemaId], keyPrefix: "player:"); var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions { DocumentRegistry = sourceRegistry, Shards = [Shard(true)] }); var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions { DocumentRegistry = targetRegistry, Shards = [Shard(false)] }); using var server = new Server(sourceCache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase); var key = new CultRecordKey("player:replica-delete"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" }; await sourceDatabase.PutAsync(key, player); await sourceDatabase.DeleteAsync(key); var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage { ShardId = "players-replica-delete", ShardEpoch = 1 }); await targetDatabase.ApplyShardLogResponseAsync(response); Assert.That(targetCache.Get(key), Is.Null); Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2)); } [Test] public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N")); var schemaId = new CultCache().Registry.GetRequired().SchemaId; var shard = new CultNetShardDescriptor( "players-durable-sequence", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "player:"); var sourceCache = new CultCache(); var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument( sourceCache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions { DocumentRegistry = sourceRegistry, MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), Shards = [shard] }); var key = new CultRecordKey("player:durable-sequence"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" }; await sourceDatabase.PutAsync(key, player); Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); sourceDatabase.Dispose(); // R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the // sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences // (what the next local commit gets), never _lastWriteSequence (what a row's own last // write was), so a selection evaluated right after restart would order/page the row as // "never written" until it was written again. var restartedCache = new CultCache(); var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions { MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), Shards = [shard] }); Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); } [Test] public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"), new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); await database.PutAsync( new CultRecordKey("a:one"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" }); await database.PutAsync( new CultRecordKey("b:one"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" }); // R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log // and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than // answered, reusing the selection_invalid door refusal (R-N) rather than a second shape. var ex = Assert.Throws(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message { Selection = new CultNetSelection { Schemas = [schemaId] } })); Assert.That(ex!.Field, Is.EqualTo("asOf")); } // S-9 (docs/cultnet-selection-cut.md, fix batch 4): asOf is the watermark of the shard(s) the // selection's matched rows actually come from, not CurrentAsOf()'s database-wide maximum across // every shard - a shard-log sequence is a per-shard counter, so a selection matching only // shard-a's row must not claim exactness as of a sequence shard-b's unrelated writes advanced to. [Test] public async Task CultNetDatabaseServer_SelectionResponseAsOf_IsTheMatchedShardsOwnWatermarkNotTheDatabaseWideMaximum() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"), new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:") ] }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); await database.PutAsync( new CultRecordKey("a:one"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" }); // shard-b takes several more writes than shard-a, so the database-wide maximum runs well // ahead of shard-a's own watermark. for (var i = 0; i < 4; i++) { await database.PutAsync( new CultRecordKey("b:one"), new PlayerData { PlayerId = Guid.NewGuid(), Email = $"b{i}@example.test", PasswordHash = "hash", Username = "B" }); } Assert.That(database.CurrentAsOf(), Is.EqualTo(4UL), "sanity: the database-wide maximum is shard-b's watermark"); Assert.That(database.CurrentAsOf("shard-a"), Is.EqualTo(1UL), "sanity: shard-a's own watermark is unaffected by shard-b's writes"); var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message { Selection = new CultNetSelection { Keys = ["a:one"] } }); Assert.That(response.AsOf, Is.EqualTo(1UL)); } [Test] public async Task CultNetDatabase_Predicts_ClientOwnedInput_AndReconciles_AuthoritativeLog() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var key = new CultRecordKey("input:client-a"); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { RuntimeId = "client-a", DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "inputs", "server", epoch: 1, isPrimary: false, schemaIds: [schemaId], keyPrefix: "input:", primaryEndpoints: ["cultnet://server.example.test:3075"]) ], ClientAuthorityScopes = [ new CultNetClientAuthorityScope( "client-a", schemaIds: [schemaId], keyPrefix: "input:client-a") ] }); var changes = new List>(); using var subscription = database.WatchRecord(key) .Subscribe(change => changes.Add(change)); var predicted = new PlayerData { PlayerId = Guid.NewGuid(), Email = "input@example.test", PasswordHash = "hash", Username = "Predicted" }; var authoritative = new PlayerData { PlayerId = predicted.PlayerId, Email = predicted.Email, PasswordHash = predicted.PasswordHash, Username = "Authoritative" }; var put = registry.CreateRawDocumentPutMessage("input-commit", new CultRecordHandle(key), authoritative); put.ShardId = "inputs"; put.ShardEpoch = 1; await database.PutPredictedAsync(key, predicted); await database.ApplyShardLogResponseAsync(new CultNetShardLogResponseMessage { ShardId = "inputs", ShardEpoch = 1, Entries = [ new CultNetShardLogEntryMessage { Sequence = 1, CommittedAt = "2026-05-19T12:00:00.0000000Z", ChangeKind = "updated", Put = put } ] }); Assert.That(cache.Get(key)!.Username, Is.EqualTo("Authoritative")); Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Predicted), Is.True); Assert.That(changes.Exists(change => change.Kind == CultNetDatabaseChangeKind.Reconciled), Is.True); } [Test] public void CultNetDatabase_Rejects_Prediction_OutsideClientAuthorityScope() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { RuntimeId = "client-a", Shards = [ new CultNetShardDescriptor( "inputs", "server", epoch: 1, isPrimary: false, schemaIds: [schemaId], keyPrefix: "input:") ], ClientAuthorityScopes = [ new CultNetClientAuthorityScope( "client-a", schemaIds: [schemaId], keyPrefix: "input:client-a") ] }); Assert.That( async () => await database.PutPredictedAsync( new CultRecordKey("input:client-b"), new PlayerData { PlayerId = Guid.NewGuid(), Email = "input-b@example.test", PasswordHash = "hash", Username = "WrongClient" }), Throws.TypeOf() .With.Property(nameof(CultNetShardAuthorityException.Reason)) .EqualTo("not_client_authority")); } [Test] public void CultNetDatabase_Rejects_ShardLogGap() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [ new CultNetShardDescriptor( "players-gap", "runtime-a", epoch: 1, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:") ] }); Assert.That( async () => await database.ApplyShardLogResponseAsync(new CultNetShardLogResponseMessage { ShardId = "players-gap", ShardEpoch = 1, Entries = [ new CultNetShardLogEntryMessage { Sequence = 2, ChangeKind = "removed", Delete = new CultNetDocumentDeleteMessage { SchemaId = schemaId, RecordKey = "player:gap", ShardId = "players-gap", ShardEpoch = 1 } } ] }), Throws.TypeOf().With.Message.Contains("log has a gap")); } [Test] public async Task CultNetShardReplicator_Pulls_AndApplies_NextBatch() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var shard = new CultNetShardDescriptor( "players-pull", "runtime-a", epoch: 2, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://primary.example.test:3075"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); var key = new CultRecordKey("player:pull"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "pull@example.test", PasswordHash = "hash", Username = "Pull" }; var put = registry.CreateRawDocumentPutMessage("put-pull", new CultRecordHandle(key), player); put.ShardId = "players-pull"; put.ShardEpoch = 2; var fetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage { ShardId = "players-pull", ShardEpoch = 2, Entries = [ new CultNetShardLogEntryMessage { Sequence = 1, CommittedAt = "2026-05-19T12:00:00.0000000Z", ChangeKind = "added", Put = put } ] }); using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions { Fetcher = fetcher, BatchSize = 7 }); var sequence = await replicator.PullOnceAsync("players-pull"); Assert.That(sequence, Is.EqualTo(1)); Assert.That(fetcher.FetchCount, Is.EqualTo(1)); Assert.That(fetcher.LastShard!.ShardId, Is.EqualTo("players-pull")); Assert.That(fetcher.LastAfterSequence, Is.EqualTo(0)); Assert.That(fetcher.LastLimit, Is.EqualTo(7)); Assert.That(cache.Get(key)!.Username, Is.EqualTo("Pull")); } [Test] public async Task CultNetShardReplicator_Resumes_From_FileCursorStore() { var path = Path.Combine(Path.GetTempPath(), $"cultnet-cursors-{Guid.NewGuid():N}.mpack"); try { var store = new CultNetFileShardReplicaCursorStore(path); await store.WriteAsync(new CultNetShardReplicaCursor { ShardId = "players-cursor", ShardEpoch = 3, LastAppliedSequence = 5, UpdatedAt = "2026-05-19T12:00:00.0000000Z" }); var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var shard = new CultNetShardDescriptor( "players-cursor", "runtime-a", epoch: 3, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://primary.example.test:3075"]); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [shard] }); var fetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage { ShardId = "players-cursor", ShardEpoch = 3 }); using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions { Fetcher = fetcher, CursorStore = store }); var sequence = await replicator.PullOnceAsync("players-cursor"); var cursor = await store.ReadAsync("players-cursor"); Assert.That(fetcher.LastAfterSequence, Is.EqualTo(5)); Assert.That(sequence, Is.EqualTo(5)); Assert.That(database.GetAppliedShardSequence("players-cursor"), Is.EqualTo(5)); Assert.That(cursor, Is.Not.Null); Assert.That(cursor!.LastAppliedSequence, Is.EqualTo(5)); } finally { if (File.Exists(path)) { File.Delete(path); } } } [Test] public async Task CultNetShardReplicator_AppliesSnapshot_WhenLogHistoryWasCompacted() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var schemaId = cache.Registry.GetRequired().SchemaId; var shard = new CultNetShardDescriptor( "players-snapshot-resync", "runtime-a", epoch: 9, isPrimary: false, schemaIds: [schemaId], keyPrefix: "player:", primaryEndpoints: ["cultnet://primary.example.test:3075"]); var keepKey = new CultRecordKey("player:keep"); var goneKey = new CultRecordKey("player:gone"); await cache.UpsertAsync( new PlayerData { PlayerId = Guid.NewGuid(), Email = "keep-old@example.test", PasswordHash = "hash", Username = "KeepOld" }, new CultRecordHandle(keepKey)); await cache.UpsertAsync( new PlayerData { PlayerId = Guid.NewGuid(), Email = "gone@example.test", PasswordHash = "hash", Username = "Gone" }, new CultRecordHandle(goneKey)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry, Shards = [shard] }); var keep = new PlayerData { PlayerId = Guid.NewGuid(), Email = "keep-new@example.test", PasswordHash = "hash", Username = "KeepNew" }; var put = registry.CreateRawDocumentPutMessage( "snapshot-keep", new CultRecordHandle(keepKey), keep); var logFetcher = new CapturingShardLogFetcher(new CultNetShardLogResponseMessage { ShardId = shard.ShardId, ShardEpoch = shard.Epoch, ResyncRequired = true, Reason = "compacted_history", CompactedThrough = 2 }); var snapshotFetcher = new CapturingShardSnapshotFetcher(new CultNetSnapshotResponseRawMessage { MessageId = "snapshot-resync", ShardId = shard.ShardId, ShardEpoch = shard.Epoch, ShardLogSequence = 4, Documents = [put.Document] }); using var replicator = new CultNetShardReplicator(database, new CultNetShardReplicatorOptions { Fetcher = logFetcher, SnapshotFetcher = snapshotFetcher }); var sequence = await replicator.PullOnceAsync(shard); Assert.That(sequence, Is.EqualTo(4)); Assert.That(database.GetAppliedShardSequence(shard.ShardId), Is.EqualTo(4)); Assert.That(snapshotFetcher.FetchCount, Is.EqualTo(1)); Assert.That(cache.Get(goneKey), Is.Null); Assert.That(cache.Get(keepKey)!.Username, Is.EqualTo("KeepNew")); } [Test] public void CultNetSimulationConsensus_Builds_QuorumCandidate_FromWitnesses() { var hitA = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); var hitB = CultNetSimulationObservation.ComputeClaimHash("hit", "charlie", "bob", "frame:100"); var consensus = new CultNetSimulationConsensus(new CultNetSimulationConsensusOptions { MinimumWitnesses = 2, QuorumRatio = 0.6d }); var candidates = consensus.BuildCandidates( [ new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = hitA, ClaimSummary = "alice hit bob first" }, new CultNetSimulationObservation { WitnessRuntimeId = "watcher-2", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = hitA }, new CultNetSimulationObservation { WitnessRuntimeId = "watcher-3", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = hitB, ClaimSummary = "charlie hit bob first" } ]); Assert.That(candidates, Has.Count.EqualTo(1)); Assert.That(candidates[0].ClaimHash, Is.EqualTo(hitA)); Assert.That(candidates[0].WitnessCount, Is.EqualTo(2)); Assert.That(candidates[0].SupportWeight, Is.EqualTo(2d)); Assert.That(candidates[0].TotalWeight, Is.EqualTo(3d)); Assert.That(candidates[0].HasQuorum, Is.True); } [Test] public void CultNetSimulationConsensus_DeduplicatesWitness_AndBreaksTiesDeterministically() { var lowerHash = CultNetSimulationObservation.ComputeClaimHash("a"); var higherHash = CultNetSimulationObservation.ComputeClaimHash("b"); if (string.CompareOrdinal(lowerHash, higherHash) > 0) { (lowerHash, higherHash) = (higherHash, lowerHash); } var consensus = new CultNetSimulationConsensus(new CultNetSimulationConsensusOptions { QuorumRatio = 0.5d }); var candidates = consensus.BuildCandidates( [ new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 1, Frame = 20, SubjectId = "door", ClaimKind = "state", ClaimHash = higherHash, Weight = 1d }, new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 1, Frame = 20, SubjectId = "door", ClaimKind = "state", ClaimHash = lowerHash, Weight = 0.5d }, new CultNetSimulationObservation { WitnessRuntimeId = "watcher-2", ShardId = "arena", ShardEpoch = 1, Frame = 20, SubjectId = "door", ClaimKind = "state", ClaimHash = lowerHash, Weight = 1d } ]); Assert.That(candidates, Has.Count.EqualTo(1)); Assert.That(candidates[0].ClaimHash, Is.EqualTo(lowerHash)); Assert.That(candidates[0].TotalWeight, Is.EqualTo(2d)); Assert.That(candidates[0].SupportWeight, Is.EqualTo(1d)); Assert.That(candidates[0].HasQuorum, Is.True); } [Test] public void CultNetSimulationObservationHub_Emits_ReactiveCandidates() { var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); using var hub = new CultNetSimulationObservationHub(new CultNetSimulationConsensusOptions { MinimumWitnesses = 2, QuorumRatio = 1d }); var observations = new List(); var candidates = new List(); using var observationSubscription = hub.WatchObservations() .Subscribe(observation => observations.Add(observation)); using var candidateSubscription = hub.WatchCandidates() .Subscribe(candidate => candidates.Add(candidate)); hub.Submit(new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 1, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash }); var current = hub.Submit(new CultNetSimulationObservationMessage { Observation = new CultNetSimulationObservation { WitnessRuntimeId = "watcher-2", ShardId = "arena", ShardEpoch = 1, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash } }); Assert.That(observations, Has.Count.EqualTo(2)); Assert.That(current, Has.Count.EqualTo(1)); Assert.That(current[0].HasQuorum, Is.True); Assert.That(candidates.Exists(candidate => candidate.HasQuorum), Is.True); } [Test] public void CultNetSimulationObservationServer_Creates_CandidateMessages() { var cache = new CultCache(); using var server = new Server(cache, DevelopmentServerSecurity); using var hub = new CultNetSimulationObservationHub(new CultNetSimulationConsensusOptions { MinimumWitnesses = 2, QuorumRatio = 1d }); using var observationServer = new CultNetSimulationObservationServer(server, hub); var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); var first = new CultNetSimulationObservationMessage { MessageId = "observe-1", Observation = new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 1, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash } }; var second = new CultNetSimulationObservationMessage { MessageId = "observe-2", Observation = new CultNetSimulationObservation { WitnessRuntimeId = "watcher-2", ShardId = "arena", ShardEpoch = 1, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash } }; observationServer.CreateCandidateMessages(first); var candidates = observationServer.CreateCandidateMessages(second); Assert.That(candidates, Has.Length.EqualTo(1)); Assert.That(candidates[0].MessageId, Is.EqualTo("observe-2")); Assert.That(candidates[0].ClaimHash, Is.EqualTo(claimHash)); Assert.That(candidates[0].HasQuorum, Is.True); } [Test] public async Task CultMeshSimulationFactCommitter_Commits_QuorumCandidate_ToShardLog() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "arena", "runtime-a", epoch: 4, isPrimary: true, schemaIds: [schemaId], keyPrefix: "simulation:") ] }); var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); var candidate = new CultNetSimulationConsensusCandidate( "arena", 4, 100, "bob", "hit", claimHash, "alice shot bob first", witnessCount: 3, supportWeight: 3d, totalWeight: 3d, hasQuorum: true); var committer = CultMesh.CreateSimulationFactCommitter(database); var handle = await committer.CommitAsync(candidate); var fact = cache.Get(handle.Key); var log = database.GetMutationLog("arena"); Assert.That(handle.Key, Is.EqualTo(CultMeshSimulationFact.CreateRecordKey(candidate))); Assert.That(fact, Is.Not.Null); Assert.That(fact!.ClaimHash, Is.EqualTo(claimHash)); Assert.That(fact.ClaimSummary, Is.EqualTo("alice shot bob first")); Assert.That(fact.Confidence, Is.EqualTo(1d)); Assert.That(log, Has.Count.EqualTo(1)); Assert.That(log[0].Kind, Is.EqualTo(CultNetDatabaseChangeKind.Added)); } [Test] public async Task CultMeshGameSession_SubmitsWitnesses_AndCommitsQuorumFact() { var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-session", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); var cachePath = Path.Combine(rootPath, "world.ccmp"); var schemaId = new CultCache().Registry.GetRequired().SchemaId; using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions { StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "arena", "runtime-a", epoch: 4, isPrimary: true, schemaIds: [schemaId], keyPrefix: "simulation:") ] } }); using var session = CultMesh.CreateGameSession(node, new CultMeshGameSessionOptions { ConsensusOptions = new CultNetSimulationConsensusOptions { MinimumWitnesses = 2, QuorumRatio = 1d }, ServeSimulationObservations = false, ServeVerseDiscovery = false, ServePeerExchange = false }); var committed = new List>(); using var facts = session.WatchSimulationFacts().Subscribe(committed.Add); var claimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"); var firstCommit = await session.SubmitAndCommitAsync(new CultNetSimulationObservation { WitnessRuntimeId = "watcher-1", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash, ClaimSummary = "alice shot bob first" }); var secondCommit = await session.SubmitAndCommitAsync(new CultNetSimulationObservation { WitnessRuntimeId = "watcher-2", ShardId = "arena", ShardEpoch = 4, Frame = 100, SubjectId = "bob", ClaimKind = "hit", ClaimHash = claimHash, ClaimSummary = "alice shot bob first" }); Assert.That(firstCommit, Is.Empty); Assert.That(secondCommit, Has.Count.EqualTo(1)); Assert.That(node.Cache.Get(secondCommit[0].Key)!.ClaimHash, Is.EqualTo(claimHash)); Assert.That(committed.Exists(change => change.Document?.ClaimHash == claimHash), Is.True); } [Test] public void CultMeshSimulationFactCommitter_Rejects_CandidateWithoutQuorum() { var cache = new CultCache(); var schemaId = cache.Registry.GetRequired().SchemaId; var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { Shards = [ new CultNetShardDescriptor( "arena", "runtime-a", epoch: 4, isPrimary: true, schemaIds: [schemaId], keyPrefix: "simulation:") ] }); var candidate = new CultNetSimulationConsensusCandidate( "arena", 4, 100, "bob", "hit", CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100"), null, witnessCount: 1, supportWeight: 1d, totalWeight: 3d, hasQuorum: false); var committer = CultMesh.CreateSimulationFactCommitter(database); Assert.That( async () => await committer.CommitAsync(candidate), Throws.TypeOf().With.Message.Contains("before quorum")); } [Test] public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument( cache.Registry, payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var key = new CultRecordKey("player:change"); var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "change@example.test", PasswordHash = "hash", Username = "Change" }; var schemaId = cache.Registry.GetRequired().SchemaId; var change = new CultNetDatabaseChange( CultNetDatabaseChangeKind.Added, key, schemaId, database.Shards[0], player, previousDocument: null); var method = typeof(CultNetDatabaseServer).GetMethod( "CreateChangeMessage", BindingFlags.Instance | BindingFlags.NonPublic)!; var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(databaseServer, new object[] { change, "sub-1", new CultNetSelection { Schemas = [schemaId], Keys = [key.Value], Projection = CultNetSelectionProjections.Document } }); Assert.That(message, Is.Not.Null); Assert.That(message!.SubscriptionId, Is.EqualTo("sub-1")); Assert.That(message.ChangeKind, Is.EqualTo("added")); Assert.That(message.Document, Is.Not.Null); Assert.That(message.Document!.Payload, Is.EqualTo(SerializePlayerDataPayload(player))); } [Test] public void CultNetDatabaseServer_Creates_Filtered_SubscriptionChange_ForSchemaAlias() { var cache = new CultCache(); var registry = new CultNetDocumentRegistry(cache.Registry) .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); using var server = new Server(cache, DevelopmentServerSecurity); using var databaseServer = new CultNetDatabaseServer(server, database); var key = new CultRecordKey("network-note:alias-change"); var note = new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias change", Revision = 11 }; var descriptor = cache.Registry.GetRequired(); var change = new CultNetDatabaseChange( CultNetDatabaseChangeKind.Added, key, descriptor.SchemaId, database.Shards[0], note, previousDocument: null); var method = typeof(CultNetDatabaseServer).GetMethod( "CreateChangeMessage", BindingFlags.Instance | BindingFlags.NonPublic)!; var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(databaseServer, new object[] { change, "sub-alias", new CultNetSelection { Schemas = ["tests.networking_note.v1"], Keys = [key.Value], Projection = CultNetSelectionProjections.Document } }); Assert.That(message, Is.Not.Null); Assert.That(message!.SubscriptionId, Is.EqualTo("sub-alias")); Assert.That(message.ChangeKind, Is.EqualTo("added")); Assert.That(message.Document, Is.Not.Null); Assert.That(message.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId)); Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType()))); } [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 subscribe = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseSubscribe); var unsubscribe = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseUnsubscribe); var change = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseChangeRaw); var ghostlight = Array.Find(response.Schemas, schema => schema.SchemaVersion == "ghostlight.agent_state.v0"); var transportProfile = Array.Find(response.Schemas, schema => schema.SchemaVersion == "cultnet.transport_profile.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(subscribe, Is.Not.Null); Assert.That(subscribe!.SchemaJson, Does.Contain("cultnet.database_subscribe.v0")); Assert.That(unsubscribe, Is.Not.Null); Assert.That(unsubscribe!.SchemaJson, Does.Contain("cultnet.database_unsubscribe.v0")); Assert.That(change, Is.Not.Null); Assert.That(change!.SchemaJson, Does.Contain("cultnet.database_change_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")); Assert.That(transportProfile, Is.Not.Null); Assert.That(transportProfile!.Kind, Is.EqualTo("shared_contract")); Assert.That(transportProfile.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); Assert.That(transportProfile.SchemaJson, Does.Contain("cultnet.transport_profile.v0")); Assert.That(transportProfile.SchemaJson, Does.Contain("reconnectPolicy")); Assert.That(transportProfile.SchemaJson, Does.Contain("cultnet.reconnect_policy.v0")); Assert.That(transportProfile.SchemaJson, Does.Contain("litenetlib")); } [Test] public void CultWitnessArtifactBundle_Has_Stable_CacheContract() { var descriptor = new CultCache().Registry.GetRequired(); Assert.That(descriptor.SchemaName, Is.EqualTo("cultnet.witness_artifact_bundle")); Assert.That(descriptor.SchemaVersion, Is.EqualTo(CultNetSchemaVersions.WitnessArtifactBundle)); Assert.That(descriptor.NameMember, Is.EqualTo(nameof(CultWitnessArtifactBundle.BundleId))); Assert.That(descriptor.CanonicalSchemaJson, Is.EqualTo( "{\"schemaName\":\"cultnet.witness_artifact_bundle\",\"schemaVersion\":\"cultnet.witness_artifact_bundle.v0\",\"members\":[{\"slot\":0,\"name\":\"BundleId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"WitnessKind\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":2,\"name\":\"CapturedAt\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":3,\"name\":\"Subject\",\"type\":\"GameCult.Networking.CultWitnessSubjectPin\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":4,\"name\":\"Contracts\",\"type\":\"GameCult.Networking.CultWitnessContractPin[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":5,\"name\":\"Artifacts\",\"type\":\"GameCult.Networking.CultWitnessArtifactEntry[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":6,\"name\":\"TimingWitnesses\",\"type\":\"GameCult.Networking.CultWitnessTimingEntry[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":7,\"name\":\"Provenance\",\"type\":\"GameCult.Networking.CultWitnessProvenance\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}")); } [Test] public void CultNetSchemaRegistry_BuiltInCatalog_Advertises_WitnessArtifactBundle() { var response = CultNetSchemaRegistry.BuiltIn.CreateCatalogResponse( new CultNetSchemaCatalogRequestMessage { MessageId = "catalog-witness", IncludeSchemaJson = true }); var witnessBundle = Array.Find(response.Schemas, schema => schema.SchemaVersion == CultNetSchemaVersions.WitnessArtifactBundle); Assert.That(witnessBundle, Is.Not.Null); Assert.That(witnessBundle!.DocumentType, Is.EqualTo("cultnet.witness-artifact-bundle")); Assert.That(witnessBundle.Kind, Is.EqualTo("document_payload")); Assert.That(witnessBundle.WireContracts, Is.EqualTo([CultNetWireContracts.SchemaV0])); Assert.That(witnessBundle.SchemaJson, Does.Contain("\"bundleId\"")); Assert.That(witnessBundle.SchemaJson, Does.Contain("\"timingWitnesses\"")); Assert.That(witnessBundle.SchemaJson, Does.Contain("\"witnessArtifactUri\"")); } [Test] public void Server_Separates_Connection_And_Auth_RateLimits() { var cache = new CultCache(); var server = new Server(cache, DevelopmentServerSecurity); var serverType = typeof(Server); var connectionMethod = serverType.GetMethod("CheckConnectionRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; var authMethod = serverType.GetMethod("CheckAuthRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; for (var index = 0; index < 5; index++) { Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); } Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); for (var index = 0; index < 5; index++) { _ = authMethod.Invoke(server, new object[] { "127.0.0.2" }); } Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.False); Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.True); } [Test] public void Client_Reconnect_Backoff_Is_Bounded_And_Grows() { var method = typeof(Client).GetMethod( "GetReconnectDelayForAttempt", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(int) }, null)!; var deterministicMethod = typeof(Client).GetMethod( "GetReconnectDelayForAttempt", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(int), typeof(int), typeof(CultNetReconnectPolicy) }, null)!; var first = (TimeSpan)method.Invoke(null, new object[] { 1 })!; var third = (TimeSpan)method.Invoke(null, new object[] { 3 })!; var tenth = (TimeSpan)method.Invoke(null, new object[] { 10 })!; Assert.That(first, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1))); Assert.That(first, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1.25))); Assert.That(third, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(4))); Assert.That(third, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(4.25))); Assert.That(tenth, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(30))); Assert.That(tenth, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(30.25))); var deterministic = (TimeSpan)deterministicMethod.Invoke( null, new object[] { 3, 17, CultNetReconnectPolicies.CreateDefault(maxAttempts: 8) })!; Assert.That(deterministic, Is.EqualTo(TimeSpan.FromMilliseconds(4_017))); } [Test] public void Client_Reconnect_Uses_Portable_Policy_Controller() { var policy = CultNetReconnectPolicies.CreateDefault("client-rudp", maxAttempts: 2); var client = new Client(DevelopmentClientSecurity, policy); Assert.That(client.ReconnectPolicy, Is.SameAs(policy)); Assert.That(client.ReconnectAttempt, Is.EqualTo(0)); Assert.That(client.NextReconnectAttemptAtMs, Is.Null); var schedule = typeof(Client).GetMethod("ScheduleReconnect", BindingFlags.Instance | BindingFlags.NonPublic)!; schedule.Invoke(client, Array.Empty()); Assert.That(client.ReconnectAttempt, Is.EqualTo(1)); Assert.That(client.NextReconnectAttemptAtMs, Is.Not.Null); Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.WaitingToReconnect)); client.Dispose(); } [Test] public void SensitivePayloadLogging_Is_Gated_By_Default() { var client = new Client(DevelopmentClientSecurity); var productionServer = new Server(new CultCache(), new ServerSecurityOptions("prod-connection-key", "prod-session-signing-secret")); var developmentServer = new Server(new CultCache(), DevelopmentServerSecurity); Assert.That(client.LogSensitivePayloads, Is.False); Assert.That(productionServer.LogSensitivePayloads, Is.False); Assert.That(developmentServer.LogSensitivePayloads, Is.True); } [Test] public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server() { var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-{Guid.NewGuid():N}.msgpack"); try { var host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); Assert.That(host.Cache, Is.Not.Null); Assert.That(host.Store, Is.Not.Null); Assert.That(host.Server, Is.Not.Null); await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "host@example.test", PasswordHash = "hash", Username = "HostUser" }); await host.FlushAsync(); host.Dispose(); var reopened = await CultCacheMessagePack.OpenAsync(filePath); Assert.That(reopened.GetByName("HostUser"), Is.Not.Null); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public void CultNetLocal_CreateClient_Uses_Development_Defaults() { var client = CultNetLocal.CreateClient(); Assert.That(client, Is.Not.Null); Assert.That(client.LogSensitivePayloads, Is.False); Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.Idle)); } [CultDocument("sample.mesh_note", "sample.mesh_note.v0")] [MessagePack.MessagePackObject] public sealed class MeshQuickstartNote { [MessagePack.Key(0)] [CultName] public string NoteId { get; set; } = string.Empty; [MessagePack.Key(1)] public string Body { get; set; } = string.Empty; } [CultDocument("tests.networking_note", "tests.networking_note.v1")] [MessagePack.MessagePackObject] public sealed class NetworkSchemaNote { [MessagePack.Key(0)] public string Schema { get; set; } = string.Empty; [MessagePack.Key(1)] public string Text { get; set; } = string.Empty; [MessagePack.Key(2)] public int Revision { get; set; } } private sealed class EnvironmentVariableScope : IDisposable { private readonly (string Name, string? Value)[] _originalValues; public EnvironmentVariableScope(params (string Name, string? Value)[] values) { _originalValues = new (string Name, string? Value)[values.Length]; for (var i = 0; i < values.Length; i++) { _originalValues[i] = (values[i].Name, Environment.GetEnvironmentVariable(values[i].Name)); Environment.SetEnvironmentVariable(values[i].Name, values[i].Value); } } public void Dispose() { foreach (var original in _originalValues) { Environment.SetEnvironmentVariable(original.Name, original.Value); } } } private sealed class CapturingShardWriteForwarder : ICultNetShardWriteForwarder { public int PutCount { get; private set; } public CultNetShardDescriptor? LastPutShard { get; private set; } public CultNetDocumentPutRawMessage? LastPutMessage { get; private set; } public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { PutCount++; LastPutShard = shard; LastPutMessage = message; return Task.CompletedTask; } public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { return Task.CompletedTask; } } private sealed class CapturingShardLogFetcher : ICultNetShardLogFetcher { private readonly CultNetShardLogResponseMessage _response; public CapturingShardLogFetcher(CultNetShardLogResponseMessage response) { _response = response; } public int FetchCount { get; private set; } public CultNetShardDescriptor? LastShard { get; private set; } public long LastAfterSequence { get; private set; } public int? LastLimit { get; private set; } public Task FetchAsync( CultNetShardDescriptor shard, long afterSequence, int? limit = null) { FetchCount++; LastShard = shard; LastAfterSequence = afterSequence; LastLimit = limit; return Task.FromResult(_response); } } private sealed class CapturingShardSnapshotFetcher : ICultNetShardSnapshotFetcher { private readonly CultNetSnapshotResponseRawMessage _response; public CapturingShardSnapshotFetcher(CultNetSnapshotResponseRawMessage response) { _response = response; } public int FetchCount { get; private set; } public CultNetShardDescriptor? LastShard { get; private set; } public Task FetchAsync(CultNetShardDescriptor shard) { FetchCount++; LastShard = shard; return Task.FromResult(_response); } } private sealed class CapturingSchemaClient : ICultNetSchemaClient { private readonly Func _respond; private readonly Dictionary> _handlers = new Dictionary>(); public CapturingSchemaClient(Func respond) { _respond = respond; } public bool Connected { get; private set; } public string? ConnectedHost { get; private set; } public int ConnectedPort { get; private set; } public List SentMessages { get; } = new List(); public void Connect(string host, int port) { Connected = true; ConnectedHost = host; ConnectedPort = port; } public void SendCultNet(T message) where T : ICultNetSchemaMessage { SentMessages.Add(message); var response = _respond(message); if (response == null) { return; } if (_handlers.TryGetValue(response.GetType(), out var handlers)) { foreach (var handler in handlers) { handler.DynamicInvoke(response); } } } public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { if (!_handlers.TryGetValue(typeof(T), out var handlers)) { handlers = new List(); _handlers[typeof(T)] = handlers; } handlers.Add(callback); } public void Dispose() { } } private sealed class NeverConnectedSchemaClient : ICultNetSchemaClient { public bool Connected => false; public void Connect(string host, int port) { } public void SendCultNet(T message) where T : ICultNetSchemaMessage { throw new InvalidOperationException("A disconnected client cannot send."); } public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { } public void Dispose() { } } private sealed class ManualCultMeshClock : ICultMeshClock { public ManualCultMeshClock(DateTimeOffset utcNow) { UtcNow = utcNow; } public DateTimeOffset UtcNow { get; private set; } public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); UtcNow += delay; return Task.CompletedTask; } } private sealed class AcceptingAuthoritySignatureVerifier : ICultMeshAuthoritySignatureVerifier { public bool Verify(CultMeshAuthorityLease lease) => string.Equals(lease.Signature, "sig", StringComparison.Ordinal); } private sealed class NoAuthorityRevocations : ICultMeshAuthorityRevocationSource { public bool IsRevoked(string leaseId, long authorityEpoch) => false; } [MessagePack.MessagePackObject] public sealed class PlayerDataPayload { [MessagePack.Key(0)] public Guid PlayerId { 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; [MessagePack.Key(4)] public long SessionVersion { get; set; } } private static byte[] SerializePlayerDataPayload(PlayerData entry) { return MessagePack.MessagePackSerializer.Serialize(new PlayerDataPayload { PlayerId = entry.PlayerId, Email = entry.Email, PasswordHash = entry.PasswordHash, Username = entry.Username, SessionVersion = entry.SessionVersion }); } private static PlayerData DeserializePlayerDataPayload(byte[] payload) { var decoded = MessagePack.MessagePackSerializer.Deserialize(payload); return new PlayerData { PlayerId = decoded.PlayerId, Email = decoded.Email, PasswordHash = decoded.PasswordHash, Username = decoded.Username, SessionVersion = decoded.SessionVersion }; } } }