A server accepting a fresh Connect can send before its Accept is acknowledged, so no ack of the new generation prunes the acknowledged sizes; only `end_session` clearing them keeps the ended generation's bytes from counting against the new window. Rust, TypeScript, Python and C# each pin it: 4 MiB acknowledged above a lost packet, a fresh Connect, and the server's first 1 MiB send of the new generation goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7594 lines
352 KiB
C#
7594 lines
352 KiB
C#
#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<TMessage>(CultNetRudpSocketTransportConnection transport)
|
|
where TMessage : class, ICultNetSchemaMessage
|
|
{
|
|
for (var attempt = 0; attempt < 20; attempt++)
|
|
{
|
|
var message = transport.ReceiveSchemaMessageOnce<TMessage>();
|
|
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<bool> 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<T> AwaitWithTimeout<T>(Task<T> 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<InvalidOperationException>().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<InvalidOperationException>()
|
|
.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<TargetInvocationException>()
|
|
.With.InnerException.InstanceOf<MessagePack.MessagePackSerializationException>());
|
|
}
|
|
|
|
[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<InvalidOperationException>(
|
|
async () => await sender.SendAsync("unreliable", Array.Empty<byte>())),
|
|
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<CultNetHelloMessage>());
|
|
|
|
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<ErrorMessage>());
|
|
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<int>();
|
|
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<InvalidOperationException>(() =>
|
|
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<InvalidOperationException>(() =>
|
|
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<InvalidOperationException>(() =>
|
|
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<CultNetRudpPacket>(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<CultNetRudpDeliveredFrame>();
|
|
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<CultNetRudpPacket> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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"));
|
|
}
|
|
|
|
/// <summary>Whether the packet's ack field and mask cover the sequence.</summary>
|
|
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<CultNetRudpPacket> DrainPackets(Socket socket)
|
|
{
|
|
var packets = new List<CultNetRudpPacket>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>A reliable frame no session accepts: a fragment set with no fragment id.</summary>
|
|
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<InvalidOperationException>(() => 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<CultNetRudpSocketServerPeer>();
|
|
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<InvalidOperationException>(() => 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<CultNetRudpSocketServerPeer>();
|
|
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<ArgumentOutOfRangeException>(() =>
|
|
new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = 1, InitialSequence = uint.MaxValue }));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
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<ArgumentOutOfRangeException>(() =>
|
|
new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions
|
|
{
|
|
RuntimeId = "csharp-rudp-listener",
|
|
Socket = socket,
|
|
ConnectionId = 1,
|
|
InitialSequence = uint.MaxValue
|
|
}));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
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<CultNetSchemaCatalogRequestMessage>(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<CultNetHelloMessage>(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<CultNetSchemaCatalogRequestMessage>((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<CultNetSchemaCatalogResponseMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(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<ArgumentOutOfRangeException>(() => 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<CultNetSchemaCatalogRequestMessage>((request, peer) =>
|
|
peer.SendCultNet(new CultNetSchemaCatalogResponseMessage
|
|
{
|
|
MessageId = request.MessageId,
|
|
Schemas = Array.Empty<CultNetSchemaDescriptor>()
|
|
}));
|
|
|
|
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<CultNetSchemaCatalogResponseMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(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<CultRecordKey>();
|
|
using var subscription = database.Watch<NetworkSchemaNote>().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<NetworkSchemaNote>(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<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
|
|
var reopened = new CultCache();
|
|
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
|
|
Assert.That(reopened.Get<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
|
|
Assert.That(reopened.Get<NetworkSchemaNote>(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<CultNetSnapshotResponseRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSnapshotResponseRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<NetworkSchemaNote>(targetCache.Registry));
|
|
var transport = CultNetSchemaClients.CreateRudp("database-reconciled-subscription-client");
|
|
using var client = new CultNetDatabaseSubscriptionClient(transport, targetCache, targetDocuments);
|
|
var changes = new ConcurrentQueue<CultNetReplicatedDocumentChange>();
|
|
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<NetworkSchemaNote>(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<string>(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<CultNetDatabaseChangeRawMessage>();
|
|
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<NetworkSchemaNote>(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<CultNetSnapshotResponseRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSnapshotResponseRawV1Message>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
|
|
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSelectionEvaluatorTests.SelLeafA>(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<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSnapshotResponseRawV1Message>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
|
|
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
|
|
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(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<CultNetDatabaseSubscriptionDemand>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var withdrawn = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
|
|
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<NetworkSchemaNote>(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<object>().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<bool>(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<NetworkSchemaNote>(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<CultNetDatabaseSubscriptionDemand>(
|
|
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<CultNetDatabaseSubscriptionDemand>(
|
|
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<CultNetDatabaseSubscriptionDemand>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var withdrawn = new TaskCompletionSource<CultNetDatabaseSubscriptionDemand>(
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>().Single().Text, Is.EqualTo("initial"));
|
|
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.TypeOf<NetworkSchemaNote>());
|
|
|
|
var changed = new TaskCompletionSource<CultNetReplicatedDocumentChange>(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<NetworkSchemaNote>());
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>().Single().Text, Is.EqualTo("initial"));
|
|
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
|
|
|
|
var changed = new TaskCompletionSource<CultNetReplicatedDocumentChange>(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<NetworkSchemaNote>(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<NetworkSchemaNote>("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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>("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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<CultNetReplicatedDocumentChange>(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<NetworkSchemaNote>(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<NetworkSchemaNote>();
|
|
var change = new CultNetDatabaseChange<NetworkSchemaNote>(
|
|
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<CultNetSnapshotResponseRawMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var serverFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
server.OnCultNet<CultNetSnapshotRequestMessage>((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<CultNetSnapshotResponseRawMessage>(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<string, byte>();
|
|
var receivedAll = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var serverFailure = new TaskCompletionSource<Exception>(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<CultNetSnapshotResponseRawMessage>(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<CultNetHelloMessage>(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<PlayerData>(
|
|
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<PlayerData>().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<PlayerData>("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<PlayerData>(
|
|
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<string>());
|
|
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<PlayerData>(
|
|
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<CultRecordHandle<PlayerData>>();
|
|
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<string>()), Is.Not.Null);
|
|
Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty<string>()), 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<CultNetDatabaseChange<PlayerData>>();
|
|
using var subscription = database
|
|
.WatchByIndex<PlayerData>("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<PlayerData>(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<PlayerData>().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<CultNetShardAuthorityException>()
|
|
.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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(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<CultNetShardAuthorityException>()
|
|
.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<PlayerData>(
|
|
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<CultNetDatabaseChange<PlayerData>>();
|
|
using var subscription = database.WatchRecord<PlayerData>(key)
|
|
.Subscribe(change => changes.Add(change));
|
|
|
|
var message = registry.CreateRawDocumentPutMessage(
|
|
"put-raw",
|
|
new CultRecordHandle<PlayerData>(key),
|
|
player);
|
|
|
|
var applied = await database.ApplyPutAsync(message);
|
|
|
|
Assert.That(applied, Is.TypeOf<PlayerData>());
|
|
Assert.That(cache.Get<PlayerData>(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<NetworkSchemaNote>(cache.Registry));
|
|
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
|
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<CultNetDatabaseChange<NetworkSchemaNote>>();
|
|
using var subscription = database.WatchRecord<NetworkSchemaNote>(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<NetworkSchemaNote>());
|
|
Assert.That(cache.Get<NetworkSchemaNote>(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<PlayerData>(
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>(cache.Registry));
|
|
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
|
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<NetworkSchemaNote>(cache.Registry));
|
|
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>(cache.Registry));
|
|
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>(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<NetworkSchemaNote>(key)!.Text, Is.EqualTo("predicted through alias scope"));
|
|
}
|
|
|
|
[Test]
|
|
public void CultNetDatabaseServer_Creates_Filtered_ShardCatalogResponse()
|
|
{
|
|
var cache = new CultCache();
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(key),
|
|
new PlayerData
|
|
{
|
|
PlayerId = Guid.NewGuid(),
|
|
Email = "forward@example.test",
|
|
PasswordHash = "hash",
|
|
Username = "Forward"
|
|
});
|
|
var exception = Assert.ThrowsAsync<CultNetShardAuthorityException>(
|
|
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<FormatException>());
|
|
}
|
|
|
|
[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<RudpCultNetSchemaClient>());
|
|
Assert.That(liteNetLib, Is.TypeOf<LiteNetLibCultNetSchemaClient>());
|
|
}
|
|
|
|
[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<CultNetSchemaCatalogRequestMessage>();
|
|
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<CultNetSchemaCatalogResponseMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.OnCultNet<CultNetSchemaCatalogResponseMessage>(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<CultMeshVerseCatalogRequestMessage>();
|
|
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<CultMeshPeerExchangeRequestMessage>();
|
|
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<CultMeshVerseCatalogRequestMessage>());
|
|
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<TimeoutException>(() =>
|
|
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<ICultNetSchemaClient>();
|
|
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<CultMeshPeerExchangeRequestMessage>());
|
|
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<CultNetShardLogRequestMessage>());
|
|
Assert.That(log.ShardId, Is.EqualTo("notes"));
|
|
Assert.That(snapshotClient.SentMessages.Single(), Is.TypeOf<CultNetSnapshotRequestMessage>());
|
|
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<CultMeshVerseDescriptor>();
|
|
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<CultMeshVerseDescriptor>();
|
|
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<CultMeshPeerCard>();
|
|
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<InvalidOperationException>(() => 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<CultNetSchemaCatalogRequestMessage>(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<PlayerData>().SchemaId;
|
|
var registry = new CultNetDocumentRegistry(cache.Registry)
|
|
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
|
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<MeshQuickstartNote>());
|
|
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<MeshQuickstartNote>(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<MeshQuickstartNote>(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<PlayerData>().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<PlayerData>(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<PlayerData>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(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<PlayerData>().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<PlayerData>(
|
|
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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(
|
|
sourceCache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
|
|
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
|
targetCache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().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<PlayerData>(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<PlayerData>(
|
|
sourceCache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
|
|
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
|
targetCache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().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<PlayerData>(key);
|
|
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
|
|
{
|
|
ShardId = "players-replica-delete",
|
|
ShardEpoch = 1
|
|
});
|
|
|
|
await targetDatabase.ApplyShardLogResponseAsync(response);
|
|
|
|
Assert.That(targetCache.Get<PlayerData>(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<PlayerData>().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<PlayerData>(
|
|
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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<CultNetSelectionInvalidException>(() => 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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<CultNetDatabaseChange<PlayerData>>();
|
|
using var subscription = database.WatchRecord<PlayerData>(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<PlayerData>(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<PlayerData>(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<PlayerData>().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<CultNetShardAuthorityException>()
|
|
.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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<InvalidOperationException>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(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<PlayerData>(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<PlayerData>().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<PlayerData>(
|
|
cache.Registry,
|
|
payloadSerializer: SerializePlayerDataPayload,
|
|
payloadDeserializer: DeserializePlayerDataPayload));
|
|
var schemaId = cache.Registry.GetRequired<PlayerData>().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<PlayerData>(keepKey));
|
|
await cache.UpsertAsync(
|
|
new PlayerData { PlayerId = Guid.NewGuid(), Email = "gone@example.test", PasswordHash = "hash", Username = "Gone" },
|
|
new CultRecordHandle<PlayerData>(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<PlayerData>(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<PlayerData>(goneKey), Is.Null);
|
|
Assert.That(cache.Get<PlayerData>(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<CultNetSimulationObservation>();
|
|
var candidates = new List<CultNetSimulationConsensusCandidate>();
|
|
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<CultMeshSimulationFact>().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<CultMeshSimulationFact>(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<CultMeshSimulationFact>().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<CultNetDatabaseChange<CultMeshSimulationFact>>();
|
|
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<CultMeshSimulationFact>(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<CultMeshSimulationFact>().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<InvalidOperationException>().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<PlayerData>(
|
|
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<PlayerData>().SchemaId;
|
|
var change = new CultNetDatabaseChange<PlayerData>(
|
|
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<NetworkSchemaNote>(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<NetworkSchemaNote>();
|
|
var change = new CultNetDatabaseChange<NetworkSchemaNote>(
|
|
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<CultWitnessArtifactBundle>();
|
|
|
|
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<object>());
|
|
|
|
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<PlayerData>("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<CultNetShardLogResponseMessage> 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<CultNetSnapshotResponseRawMessage> FetchAsync(CultNetShardDescriptor shard)
|
|
{
|
|
FetchCount++;
|
|
LastShard = shard;
|
|
return Task.FromResult(_response);
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingSchemaClient : ICultNetSchemaClient
|
|
{
|
|
private readonly Func<ICultNetSchemaMessage, ICultNetSchemaMessage?> _respond;
|
|
private readonly Dictionary<Type, List<Delegate>> _handlers = new Dictionary<Type, List<Delegate>>();
|
|
|
|
public CapturingSchemaClient(Func<ICultNetSchemaMessage, ICultNetSchemaMessage?> respond)
|
|
{
|
|
_respond = respond;
|
|
}
|
|
|
|
public bool Connected { get; private set; }
|
|
public string? ConnectedHost { get; private set; }
|
|
public int ConnectedPort { get; private set; }
|
|
public List<ICultNetSchemaMessage> SentMessages { get; } = new List<ICultNetSchemaMessage>();
|
|
|
|
public void Connect(string host, int port)
|
|
{
|
|
Connected = true;
|
|
ConnectedHost = host;
|
|
ConnectedPort = port;
|
|
}
|
|
|
|
public void SendCultNet<T>(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<T>(Action<T> callback)
|
|
where T : ICultNetSchemaMessage
|
|
{
|
|
if (!_handlers.TryGetValue(typeof(T), out var handlers))
|
|
{
|
|
handlers = new List<Delegate>();
|
|
_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>(T message) where T : ICultNetSchemaMessage
|
|
{
|
|
throw new InvalidOperationException("A disconnected client cannot send.");
|
|
}
|
|
|
|
public void OnCultNet<T>(Action<T> 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<PlayerDataPayload>(payload);
|
|
return new PlayerData
|
|
{
|
|
PlayerId = decoded.PlayerId,
|
|
Email = decoded.Email,
|
|
PasswordHash = decoded.PasswordHash,
|
|
Username = decoded.Username,
|
|
SessionVersion = decoded.SessionVersion
|
|
};
|
|
}
|
|
}
|
|
}
|