CI / build-test (push) Successful in 1m12s
Robustness pass over MrGameEng.Net: - WebSocketServer heartbeats each connection (configurable interval/timeout) and drops peers idle past the timeout — detects half-open TCP that vanished without a close frame. Tracks last-activity per connection. - Reassembled messages capped (server and client) so a peer cannot exhaust memory with an oversized fragmented message. - Replication snapshots carry a protocol-version byte; a client receiving a mismatched version drops the message instead of decoding garbage, and a truncated snapshot is ignored without throwing. - ReplicationClient.Clear() deletes all replicated entities, so clients can wipe stale state before reconnecting. Tests: heartbeat healthy-survives / silent-peer-dropped, protocol-version mismatch, truncated snapshot, replication clear. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
276 lines
9.5 KiB
C#
276 lines
9.5 KiB
C#
using System.Collections.Concurrent;
|
|
using Friflo.Engine.ECS;
|
|
using MrGameEng.Net;
|
|
using Xunit;
|
|
|
|
namespace MrGameEng.Net.Tests;
|
|
|
|
public class ReplicationTests
|
|
{
|
|
private struct TestPosition : IComponent
|
|
{
|
|
public float X;
|
|
public float Y;
|
|
}
|
|
|
|
private struct TestHealth : IComponent
|
|
{
|
|
public int Value;
|
|
}
|
|
|
|
private sealed class FakeConnection : INetConnection
|
|
{
|
|
public int Id { get; init; }
|
|
public bool IsOpen { get; set; } = true;
|
|
public readonly ConcurrentQueue<byte[]> Sent = new();
|
|
|
|
public void Send(ReadOnlySpan<byte> message) => Sent.Enqueue(message.ToArray());
|
|
|
|
public bool TryReceive(out byte[] message) => Sent.TryDequeue(out message!);
|
|
|
|
public void Close() => IsOpen = false;
|
|
}
|
|
|
|
private static ReplicationSchema MakeSchema() =>
|
|
new ReplicationSchema().Register<TestPosition>().Register<TestHealth>();
|
|
|
|
[Fact]
|
|
public void FirstSnapshot_SpawnsEntities_OnTheClient()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 10f, Y = 20f },
|
|
new TestHealth { Value = 7 }
|
|
);
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = -3f, Y = 4f }
|
|
);
|
|
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
|
|
Assert.Equal(2, client.EntityCount);
|
|
var first = FindByNetId(clientStore, 1);
|
|
Assert.Equal(10f, first.GetComponent<TestPosition>().X);
|
|
Assert.Equal(7, first.GetComponent<TestHealth>().Value);
|
|
var second = FindByNetId(clientStore, 2);
|
|
Assert.False(second.HasComponent<TestHealth>());
|
|
}
|
|
|
|
[Fact]
|
|
public void UnchangedWorld_SendsNothing()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var connection = new FakeConnection();
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 1f }
|
|
);
|
|
|
|
server.Send([connection]);
|
|
Assert.Single(connection.Sent);
|
|
|
|
server.Send([connection]);
|
|
Assert.Single(connection.Sent); // дельта пустая — второго сообщения нет
|
|
}
|
|
|
|
[Fact]
|
|
public void ChangedComponent_IsTheOnlyThingResent()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
|
|
var entity = serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 1f },
|
|
new TestHealth { Value = 100 }
|
|
);
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
|
|
entity.AddComponent(new TestPosition { X = 5f, Y = 6f }); // здоровье не трогаем
|
|
server.Send([connection]);
|
|
|
|
Assert.True(connection.Sent.TryDequeue(out var delta));
|
|
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
|
Assert.Equal(23, delta!.Length);
|
|
|
|
client.Apply(delta);
|
|
var replicated = FindByNetId(clientStore, 1);
|
|
Assert.Equal(5f, replicated.GetComponent<TestPosition>().X);
|
|
Assert.Equal(100, replicated.GetComponent<TestHealth>().Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeletedEntity_DespawnsOnTheClient()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
|
|
var entity = serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 1f }
|
|
);
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
Assert.Equal(1, client.EntityCount);
|
|
|
|
entity.DeleteEntity();
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
|
|
Assert.Equal(0, client.EntityCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LateJoiner_GetsFullState()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var early = new FakeConnection { Id = 1 };
|
|
var late = new FakeConnection { Id = 2 };
|
|
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 9f, Y = 9f }
|
|
);
|
|
server.Send([early]);
|
|
server.Send([early, late]); // мир не менялся: early — тишина, late — полный стейт
|
|
|
|
Assert.Single(early.Sent);
|
|
Assert.Single(late.Sent);
|
|
|
|
var clientStore = new EntityStore();
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
client.Pump(late);
|
|
Assert.Equal(1, client.EntityCount);
|
|
Assert.Equal(9f, FindByNetId(clientStore, 1).GetComponent<TestPosition>().X);
|
|
}
|
|
|
|
[Fact]
|
|
public void EntitySpawned_FiresOncePerEntity()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
var spawns = 0;
|
|
client.EntitySpawned += _ => spawns++;
|
|
|
|
var entity = serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 1f }
|
|
);
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
entity.AddComponent(new TestPosition { X = 2f, Y = 2f });
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
|
|
Assert.Equal(1, spawns);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_DeletesEveryReplicatedEntity()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 2f }
|
|
);
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 3f, Y = 4f }
|
|
);
|
|
server.Send([connection]);
|
|
client.Pump(connection);
|
|
Assert.Equal(2, client.EntityCount);
|
|
|
|
client.Clear();
|
|
|
|
Assert.Equal(0, client.EntityCount);
|
|
foreach (var entity in clientStore.Entities)
|
|
{
|
|
Assert.False(entity.HasComponent<NetId>());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MismatchedProtocolVersion_IsDropped()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 1f, Y = 2f }
|
|
);
|
|
server.Send([connection]);
|
|
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
|
|
|
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
|
|
snapshot![1] = 0xFF;
|
|
client.Apply(snapshot);
|
|
|
|
Assert.Equal(0, client.EntityCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
|
|
{
|
|
var serverStore = new EntityStore();
|
|
var clientStore = new EntityStore();
|
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
|
var connection = new FakeConnection();
|
|
serverStore.CreateEntity(
|
|
new NetId { Value = server.NextNetId() },
|
|
new TestPosition { X = 5f, Y = 6f },
|
|
new TestHealth { Value = 9 }
|
|
);
|
|
server.Send([connection]);
|
|
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
|
|
|
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
|
|
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
|
|
client.Apply(truncated); // не должно бросить
|
|
|
|
// Записи могли частично примениться, но клиент остался живым и консистентным.
|
|
Assert.True(client.EntityCount <= 1);
|
|
}
|
|
|
|
private static Entity FindByNetId(EntityStore store, int netId)
|
|
{
|
|
foreach (var entity in store.Entities)
|
|
{
|
|
if (entity.HasComponent<NetId>() && entity.GetComponent<NetId>().Value == netId)
|
|
{
|
|
return entity;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException($"Entity with NetId {netId} not found.");
|
|
}
|
|
}
|