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 Sent = new(); public void Send(ReadOnlySpan 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().Register(); [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().X); Assert.Equal(7, first.GetComponent().Value); var second = FindByNetId(clientStore, 2); Assert.False(second.HasComponent()); } [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().X); Assert.Equal(100, replicated.GetComponent().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().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()); } } [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() && entity.GetComponent().Value == netId) { return entity; } } throw new InvalidOperationException($"Entity with NetId {netId} not found."); } }