Add C# RUDP channel ergonomics

This commit is contained in:
Meta Vens 2026-06-15 23:52:29 +01:00
commit a40e0f248b
2 changed files with 215 additions and 0 deletions

View file

@ -1272,6 +1272,59 @@ namespace GameCult.Networking
SendPacket(_session.CreateConnect(NowMs(), payload ?? Array.Empty<byte>()));
}
/// <summary>
/// Sends the client connect packet with a UTF-8 payload.
/// </summary>
public void Connect(string payload)
{
Connect(Encoding.UTF8.GetBytes(payload ?? string.Empty));
}
/// <summary>
/// Sends the client connect packet and polls until the handshake completes or times out.
/// </summary>
public bool ConnectAndWait(
byte[]? payload = null,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null)
{
Connect(payload);
return AwaitConnected(timeout, pollInterval);
}
/// <summary>
/// Sends the client connect packet with a UTF-8 payload and polls until the handshake completes or times out.
/// </summary>
public bool ConnectAndWait(
string payload,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null)
{
return ConnectAndWait(Encoding.UTF8.GetBytes(payload ?? string.Empty), timeout, pollInterval);
}
/// <summary>
/// Polls the transport until the RUDP handshake completes or times out.
/// </summary>
public bool AwaitConnected(TimeSpan? timeout = null, TimeSpan? pollInterval = null)
{
var deadline = DateTimeOffset.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(1));
var interval = pollInterval ?? TimeSpan.FromMilliseconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
_ = ReceiveOnce();
PollResends();
if (Connected)
{
return true;
}
Thread.Sleep(interval);
}
return Connected;
}
/// <summary>
/// Sends a logical transport frame through the RUDP session.
/// </summary>
@ -1284,6 +1337,22 @@ namespace GameCult.Networking
_stats.FramesSent++;
}
/// <summary>
/// Sends a reliable ordered schema-channel payload.
/// </summary>
public void SendSchema(byte[] payload)
{
Send("schema", payload);
}
/// <summary>
/// Sends a reliable ordered schema-channel UTF-8 payload.
/// </summary>
public void SendSchema(string payload)
{
SendSchema(Encoding.UTF8.GetBytes(payload ?? string.Empty));
}
/// <summary>
/// Sends a CultNet schema-v0 message on the reliable ordered schema channel.
/// </summary>
@ -1294,6 +1363,38 @@ namespace GameCult.Networking
Send("schema", CultNetSchemaMessageSerialization.Serialize(message));
}
/// <summary>
/// Sends an unreliable sequenced latest-state payload.
/// </summary>
public void SendLatest(byte[] payload)
{
Send("latest", payload);
}
/// <summary>
/// Sends an unreliable sequenced latest-state UTF-8 payload.
/// </summary>
public void SendLatest(string payload)
{
SendLatest(Encoding.UTF8.GetBytes(payload ?? string.Empty));
}
/// <summary>
/// Sends an unreliable unordered realtime payload.
/// </summary>
public void SendRealtime(byte[] payload)
{
Send("realtime", payload);
}
/// <summary>
/// Sends an unreliable unordered realtime UTF-8 payload.
/// </summary>
public void SendRealtime(string payload)
{
SendRealtime(Encoding.UTF8.GetBytes(payload ?? string.Empty));
}
/// <summary>
/// Attempts to receive the next CultNet schema-v0 message.
/// </summary>
@ -1325,6 +1426,61 @@ namespace GameCult.Networking
return ReceiveSchemaMessageOnce() as TMessage;
}
/// <summary>
/// Polls until a delivered transport frame matches the predicate or the timeout expires.
/// </summary>
public CultNetTransportFrame? ReceiveUntil(
TimeSpan timeout,
Func<CultNetTransportFrame, bool>? predicate = null,
TimeSpan? pollInterval = null)
{
var deadline = DateTimeOffset.UtcNow.Add(timeout);
var interval = pollInterval ?? TimeSpan.FromMilliseconds(5);
predicate ??= _ => true;
while (DateTimeOffset.UtcNow < deadline)
{
var frame = ReceiveOnce();
if (frame != null && predicate(frame))
{
return frame;
}
PollResends();
Thread.Sleep(interval);
}
return null;
}
/// <summary>
/// Polls until a schema-channel payload arrives or the timeout expires.
/// </summary>
public byte[]? ReceiveSchema(TimeSpan timeout, TimeSpan? pollInterval = null)
{
return ReceiveUntil(
timeout,
frame => string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal),
pollInterval)?.Payload;
}
/// <summary>
/// Polls until a CultNet schema-v0 message arrives or the timeout expires.
/// </summary>
public ICultNetSchemaMessage? ReceiveSchemaMessage(TimeSpan timeout, TimeSpan? pollInterval = null)
{
var payload = ReceiveSchema(timeout, pollInterval);
return payload == null ? null : CultNetSchemaMessageSerialization.Deserialize(payload);
}
/// <summary>
/// Polls until a CultNet schema-v0 message of the requested type arrives or the timeout expires.
/// </summary>
public TMessage? ReceiveSchemaMessage<TMessage>(TimeSpan timeout, TimeSpan? pollInterval = null)
where TMessage : class, ICultNetSchemaMessage
{
return ReceiveSchemaMessage(timeout, pollInterval) as TMessage;
}
/// <summary>
/// Sends a transport-level disconnect packet.
/// </summary>

View file

@ -721,6 +721,65 @@ namespace GameCult.Networking.Tests
Assert.That(profile.Transports[0].Protocol, Is.EqualTo("rudp"));
}
[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()
{