From 3438ed77f66d0a88d5dda640bfbd7a60b4a2de2f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 23:35:44 +0300 Subject: [PATCH] Add MrGameEng.Net: WebSocket transport + delta component replication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers can't speak UDP, so WebSocket is the engine's one transport. The server side is a dependency-free RFC 6455 implementation over TcpListener (handshake, frame codec with masking and fragmentation, ping/pong — unit-tested against the RFC example vectors); the client wraps ClientWebSocket, which works on desktop and maps to the browser WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection so simulation systems drain messages from their own thread; client sends are chained fire-and-forget (no blocking — wasm-safe). Replication is server-authoritative: games register unmanaged component types in a ReplicationSchema (same order both sides, up to 32 types), ReplicationServer snapshots entities carrying NetId once per send and ships each connection only the components that changed since its last snapshot — a reliable ordered transport needs no acks for deltas. New connections receive the full state through the same path; despawns are tracked by set difference. ReplicationClient applies snapshots to a local EntityStore and raises EntitySpawned so the game can decorate replicated entities with presentation components. Covered by 16 tests including a real loopback exchange between WebSocketClient and WebSocketServer. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 + MrGameEng.sln | 30 ++ docs/architecture.md | 2 + src/MrGameEng.Net/INetConnection.cs | 25 ++ src/MrGameEng.Net/MrGameEng.Net.csproj | 18 ++ src/MrGameEng.Net/Replication/NetId.cs | 14 + .../Replication/ReplicationClient.cs | 92 ++++++ .../Replication/ReplicationSchema.cs | 64 ++++ .../Replication/ReplicationServer.cs | 174 +++++++++++ src/MrGameEng.Net/WebSocketClient.cs | 141 +++++++++ src/MrGameEng.Net/WebSocketProtocol.cs | 248 ++++++++++++++++ src/MrGameEng.Net/WebSocketServer.cs | 279 ++++++++++++++++++ .../MrGameEng.Net.Tests.csproj | 17 ++ tests/MrGameEng.Net.Tests/ReplicationTests.cs | 200 +++++++++++++ .../WebSocketLoopbackTests.cs | 93 ++++++ .../WebSocketProtocolTests.cs | 88 ++++++ 16 files changed, 1492 insertions(+) create mode 100644 src/MrGameEng.Net/INetConnection.cs create mode 100644 src/MrGameEng.Net/MrGameEng.Net.csproj create mode 100644 src/MrGameEng.Net/Replication/NetId.cs create mode 100644 src/MrGameEng.Net/Replication/ReplicationClient.cs create mode 100644 src/MrGameEng.Net/Replication/ReplicationSchema.cs create mode 100644 src/MrGameEng.Net/Replication/ReplicationServer.cs create mode 100644 src/MrGameEng.Net/WebSocketClient.cs create mode 100644 src/MrGameEng.Net/WebSocketProtocol.cs create mode 100644 src/MrGameEng.Net/WebSocketServer.cs create mode 100644 tests/MrGameEng.Net.Tests/MrGameEng.Net.Tests.csproj create mode 100644 tests/MrGameEng.Net.Tests/ReplicationTests.cs create mode 100644 tests/MrGameEng.Net.Tests/WebSocketLoopbackTests.cs create mode 100644 tests/MrGameEng.Net.Tests/WebSocketProtocolTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 182c298..2ce6be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,13 @@ Engine libraries (each feature is a namespaced subfolder of its host): **Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems). → `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`). +- **`Net`** — multiplayer building blocks, browser-compatible by design: a dependency-free + RFC 6455 WebSocket server over `TcpListener` (browsers can't speak UDP, so WebSocket is + the engine's one transport), a `ClientWebSocket`-based client (works in Blazor WASM), + both behind the poll-based `INetConnection`; server-authoritative component replication + (`ReplicationSchema` of unmanaged components, `ReplicationServer` sending per-connection + deltas — no acks needed over a reliable ordered transport, `ReplicationClient` applying + snapshots to a local store, `NetId`). → `Core`. - **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`), **DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`, `scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a diff --git a/MrGameEng.sln b/MrGameEng.sln index b93040a..45fd789 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "src\MrGameEng.Net\MrGameEng.Net.csproj", "{A4E754C7-C5FD-43A2-B345-34152D3A22D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -257,6 +261,30 @@ Global {B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU {B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU {B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.Build.0 = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.Build.0 = Debug|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.Build.0 = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.ActiveCfg = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.Build.0 = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.ActiveCfg = Release|Any CPU + {A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.Build.0 = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.Build.0 = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.ActiveCfg = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.Build.0 = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.ActiveCfg = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.Build.0 = Debug|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.ActiveCfg = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.Build.0 = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.ActiveCfg = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.Build.0 = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.ActiveCfg = Release|Any CPU + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -279,5 +307,7 @@ Global {4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A4E754C7-C5FD-43A2-B345-34152D3A22D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {434C24AC-08C6-483E-A2DE-6DBC8A4DE791} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index b700d30..c7dc48d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ | `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) | | `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента | | `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast | +| `MrGameEng.Net` | Мультиплеер, совместимый с браузером по построению: WebSocket-сервер (RFC 6455 поверх `TcpListener`, без зависимостей — браузер не умеет UDP, поэтому транспорт движка один — WebSocket), клиент на `ClientWebSocket` (работает в Blazor WASM), оба за poll-интерфейсом `INetConnection`; server-authoritative репликация компонентов: `ReplicationSchema` (unmanaged-компоненты, до 32 типов), `ReplicationServer` (пер-соединенческие дельты против последнего отправленного — ack не нужны поверх надёжного упорядоченного транспорта), `ReplicationClient` (применение снапшотов в локальный `EntityStore`), компонент `NetId` | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) | @@ -72,6 +73,7 @@ ``` MrGameEng.Host ─┐ MrGameEng.Audio ─┤ +MrGameEng.Net ─┤ MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS │ MrGameEng.Content ─┤ diff --git a/src/MrGameEng.Net/INetConnection.cs b/src/MrGameEng.Net/INetConnection.cs new file mode 100644 index 0000000..d2930d5 --- /dev/null +++ b/src/MrGameEng.Net/INetConnection.cs @@ -0,0 +1,25 @@ +namespace MrGameEng.Net; + +/// +/// A bidirectional, reliable, ordered binary message channel (a WebSocket under the hood). +/// Receiving is poll-based to fit the simulation loop: incoming messages queue up on a +/// background reader and are drained with from the tick. Sending +/// never blocks the caller. Implementations are safe to use from one simulation thread. +/// +public interface INetConnection +{ + /// Connection id, unique within its owner (server-assigned; 0 for a client's own connection). + int Id { get; } + + /// False once the peer disconnected or the connection failed; sends become no-ops. + bool IsOpen { get; } + + /// Queues one binary message for delivery. No-op when the connection is closed. + void Send(ReadOnlySpan message); + + /// Dequeues the next received binary message, if any. + bool TryReceive(out byte[] message); + + /// Closes the connection. + void Close(); +} diff --git a/src/MrGameEng.Net/MrGameEng.Net.csproj b/src/MrGameEng.Net/MrGameEng.Net.csproj new file mode 100644 index 0000000..b13ee67 --- /dev/null +++ b/src/MrGameEng.Net/MrGameEng.Net.csproj @@ -0,0 +1,18 @@ + + + net8.0 + true + + + + + + + + + + + + + + diff --git a/src/MrGameEng.Net/Replication/NetId.cs b/src/MrGameEng.Net/Replication/NetId.cs new file mode 100644 index 0000000..2f833ca --- /dev/null +++ b/src/MrGameEng.Net/Replication/NetId.cs @@ -0,0 +1,14 @@ +using Friflo.Engine.ECS; + +namespace MrGameEng.Net; + +/// +/// Marks an entity as replicated and identifies it across the network. The server assigns +/// values (see ); the client creates a local +/// entity with the same when the first snapshot arrives. +/// +public struct NetId : IComponent +{ + /// Network-wide entity id, unique per server world. + public int Value; +} diff --git a/src/MrGameEng.Net/Replication/ReplicationClient.cs b/src/MrGameEng.Net/Replication/ReplicationClient.cs new file mode 100644 index 0000000..4f6115d --- /dev/null +++ b/src/MrGameEng.Net/Replication/ReplicationClient.cs @@ -0,0 +1,92 @@ +using Friflo.Engine.ECS; + +namespace MrGameEng.Net; + +/// +/// Client side of replication: applies snapshot messages from a +/// to a local . Unknown net ids +/// spawn local entities (carrying ), known ones get their changed +/// components overwritten, despawns delete. The game decorates replicated entities with +/// presentation components (sprites etc.) on top — replication never touches types outside +/// its . +/// +public sealed class ReplicationClient +{ + /// Number of replicated entities currently alive locally. + public int EntityCount => _entities.Count; + + /// Raised after an entity is created from a snapshot. Hook presentation setup here. + public event Action? EntitySpawned; + + private readonly ReplicationSchema _schema; + private readonly EntityStore _store; + private readonly Dictionary _entities = []; + + /// Creates a replication client writing into . + public ReplicationClient(ReplicationSchema schema, EntityStore store) + { + _schema = schema; + _store = store; + } + + /// Applies every message queued on . + public void Pump(INetConnection connection) + { + while (connection.TryReceive(out var message)) + { + Apply(message); + } + } + + /// Applies one snapshot message to the local store. + public void Apply(byte[] message) + { + using var reader = new BinaryReader(new MemoryStream(message)); + if (reader.ReadByte() != ReplicationMessage.Snapshot) + { + return; // незнакомый тип сообщения — пропускаем, это не снапшот + } + + var slots = _schema.Slots; + var count = reader.ReadInt32(); + for (var record = 0; record < count; record++) + { + var netId = reader.ReadInt32(); + var op = reader.ReadByte(); + if (op == ReplicationMessage.OpDespawn) + { + if (_entities.Remove(netId, out var dead)) + { + dead.DeleteEntity(); + } + + continue; + } + + var mask = reader.ReadUInt32(); + var spawned = false; + if (!_entities.TryGetValue(netId, out var entity)) + { + entity = _store.CreateEntity(new NetId { Value = netId }); + _entities[netId] = entity; + spawned = true; + } + + foreach (var slot in slots) + { + if ((mask & (1u << slot.Bit)) == 0) + { + continue; + } + + var data = reader.ReadBytes(slot.Size); + slot.Apply(entity, data, 0); + } + + if (spawned) + { + EntitySpawned?.Invoke(entity); + } + } + } +} diff --git a/src/MrGameEng.Net/Replication/ReplicationSchema.cs b/src/MrGameEng.Net/Replication/ReplicationSchema.cs new file mode 100644 index 0000000..8082b1e --- /dev/null +++ b/src/MrGameEng.Net/Replication/ReplicationSchema.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Friflo.Engine.ECS; + +namespace MrGameEng.Net; + +/// +/// The set of component types a game replicates, registered in the same order on the +/// server and every client (the order defines the wire ids). Components must be +/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client +/// must run on the same engine version. Up to 32 types. +/// +public sealed class ReplicationSchema +{ + internal sealed class ComponentSlot + { + public required int Bit; + public required int Size; + public required Func TryWrite; + public required Action Apply; + } + + internal readonly List Slots = []; + + /// + /// Registers component type for replication. Returns this + /// schema for fluent chaining. + /// + public ReplicationSchema Register() + where T : unmanaged, IComponent + { + if (Slots.Count == 32) + { + throw new InvalidOperationException( + "ReplicationSchema supports at most 32 component types." + ); + } + + var size = Unsafe.SizeOf(); + Slots.Add( + new ComponentSlot + { + Bit = Slots.Count, + Size = size, + TryWrite = (entity, buffer) => + { + if (!entity.HasComponent()) + { + return false; + } + + MemoryMarshal.Write(buffer, in entity.GetComponent()); + return true; + }, + Apply = (entity, data, offset) => + { + var value = MemoryMarshal.Read(data.AsSpan(offset, size)); + entity.AddComponent(value); + }, + } + ); + return this; + } +} diff --git a/src/MrGameEng.Net/Replication/ReplicationServer.cs b/src/MrGameEng.Net/Replication/ReplicationServer.cs new file mode 100644 index 0000000..65f0335 --- /dev/null +++ b/src/MrGameEng.Net/Replication/ReplicationServer.cs @@ -0,0 +1,174 @@ +using Friflo.Engine.ECS; + +namespace MrGameEng.Net; + +/// +/// Server-authoritative component replication. Each call to snapshots +/// every entity carrying and sends each connection only what changed +/// since that connection's previous snapshot (per-component deltas; a new connection gets +/// the full state the same way). Deltas need no acknowledgements because the transport is +/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick), +/// from the simulation thread. +/// +public sealed class ReplicationServer +{ + private sealed class ConnectionState + { + // По netId: последний отправленный блоб каждого зарегистрированного компонента. + public readonly Dictionary LastSent = []; + } + + private readonly ReplicationSchema _schema; + private readonly ArchetypeQuery _query; + private readonly Dictionary _states = []; + + // Снапшот текущего тика, переиспользуется между соединениями. + private readonly List<(int NetId, byte[]?[] Components)> _current = []; + private readonly HashSet _currentIds = []; + private int _nextNetId; + + /// Creates a replication server over . + public ReplicationServer(ReplicationSchema schema, EntityStore store) + { + _schema = schema; + _query = store.Query(); + } + + /// Allocates the next free network id for a newly spawned replicated entity. + public int NextNetId() => ++_nextNetId; + + /// + /// Snapshots the world once and sends per-connection deltas. Closed connections are + /// forgotten; brand-new ones receive the full state. + /// + public void Send(IReadOnlyList connections) + { + CaptureCurrentState(); + + foreach (var connection in connections) + { + if (!connection.IsOpen) + { + continue; + } + + if (!_states.TryGetValue(connection, out var state)) + { + state = new ConnectionState(); + _states[connection] = state; + } + + var message = BuildDelta(state); + if (message is not null) + { + connection.Send(message); + } + } + + // Забываем состояние умерших соединений, чтобы не копить мусор. + foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList()) + { + _states.Remove(dead); + } + } + + private void CaptureCurrentState() + { + _current.Clear(); + _currentIds.Clear(); + var slots = _schema.Slots; + _query.ForEachEntity( + (ref NetId netId, Entity entity) => + { + var components = new byte[]?[slots.Count]; + foreach (var slot in slots) + { + var buffer = new byte[slot.Size]; + components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null; + } + + _current.Add((netId.Value, components)); + _currentIds.Add(netId.Value); + } + ); + } + + private byte[]? BuildDelta(ConnectionState state) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write(ReplicationMessage.Snapshot); + var countPosition = stream.Position; + writer.Write(0); // количество записей, допишем в конце + var records = 0; + + foreach (var (netId, components) in _current) + { + if (!state.LastSent.TryGetValue(netId, out var lastSent)) + { + lastSent = new byte[]?[components.Length]; + state.LastSent[netId] = lastSent; + } + + uint mask = 0; + for (var bit = 0; bit < components.Length; bit++) + { + var current = components[bit]; + if (current is null) + { + continue; + } + + if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit])) + { + mask |= 1u << bit; + lastSent[bit] = current; + } + } + + if (mask == 0) + { + continue; + } + + writer.Write(netId); + writer.Write(ReplicationMessage.OpUpsert); + writer.Write(mask); + for (var bit = 0; bit < components.Length; bit++) + { + if ((mask & (1u << bit)) != 0) + { + writer.Write(components[bit]!); + } + } + + records++; + } + + // Сущности, которые соединение знает, а в мире их больше нет. + foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList()) + { + state.LastSent.Remove(known); + writer.Write(known); + writer.Write(ReplicationMessage.OpDespawn); + records++; + } + + if (records == 0) + { + return null; + } + + stream.Position = countPosition; + writer.Write(records); + return stream.ToArray(); + } +} + +/// Wire constants shared by and . +internal static class ReplicationMessage +{ + internal const byte Snapshot = 1; + internal const byte OpUpsert = 0; + internal const byte OpDespawn = 1; +} diff --git a/src/MrGameEng.Net/WebSocketClient.cs b/src/MrGameEng.Net/WebSocketClient.cs new file mode 100644 index 0000000..be31f2f --- /dev/null +++ b/src/MrGameEng.Net/WebSocketClient.cs @@ -0,0 +1,141 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; + +namespace MrGameEng.Net; + +/// +/// Client side of : a thin wrapper over the BCL +/// , which works on desktop and inside Blazor WebAssembly +/// (where it maps to the browser's WebSocket). Receiving runs on a background task into a +/// queue; sends are chained fire-and-forget so the caller — and the browser's single +/// thread — never blocks. +/// +public sealed class WebSocketClient : INetConnection, IDisposable +{ + /// + public int Id => 0; + + /// + public bool IsOpen => !_closed && _socket.State == WebSocketState.Open; + + private readonly ClientWebSocket _socket; + private readonly ConcurrentQueue _inbox = new(); + private readonly CancellationTokenSource _shutdown = new(); + private Task _sendTail = Task.CompletedTask; + private volatile bool _closed; + + private WebSocketClient(ClientWebSocket socket) => _socket = socket; + + /// + /// Connects to (ws:// or wss://) and starts the receive loop. + /// + public static async Task ConnectAsync( + Uri uri, + CancellationToken cancellationToken = default + ) + { + var socket = new ClientWebSocket(); + await socket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + var client = new WebSocketClient(socket); + _ = Task.Run(client.ReceiveLoop); + return client; + } + + /// + public void Send(ReadOnlySpan message) + { + if (!IsOpen) + { + return; + } + + var copy = message.ToArray(); + // Отправки сцеплены в хвост: ClientWebSocket не терпит параллельных SendAsync, + // а блокировать поток нельзя (в wasm это смерть). + lock (_shutdown) + { + _sendTail = _sendTail.ContinueWith( + _ => SendCore(copy), + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default + ); + } + } + + private async Task SendCore(byte[] message) + { + try + { + await _socket + .SendAsync( + message, + WebSocketMessageType.Binary, + endOfMessage: true, + _shutdown.Token + ) + .ConfigureAwait(false); + } + catch (Exception) + { + Close(); + } + } + + /// + public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!); + + /// + public void Close() + { + if (_closed) + { + return; + } + + _closed = true; + _shutdown.Cancel(); + _socket.Dispose(); + } + + /// Closes the connection. + public void Dispose() => Close(); + + private async Task ReceiveLoop() + { + var buffer = new byte[64 * 1024]; + var message = new MemoryStream(); + try + { + while (!_closed) + { + var result = await _socket + .ReceiveAsync(buffer, _shutdown.Token) + .ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + if (result.MessageType == WebSocketMessageType.Binary) + { + _inbox.Enqueue(message.ToArray()); + } + + message.SetLength(0); + } + } + } + catch (Exception) + { + // обрыв или закрытие — штатное завершение цикла + } + finally + { + Close(); + } + } +} diff --git a/src/MrGameEng.Net/WebSocketProtocol.cs b/src/MrGameEng.Net/WebSocketProtocol.cs new file mode 100644 index 0000000..d6879f4 --- /dev/null +++ b/src/MrGameEng.Net/WebSocketProtocol.cs @@ -0,0 +1,248 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace MrGameEng.Net; + +/// WebSocket frame opcodes used by the server. +internal enum WebSocketOpcode : byte +{ + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + Close = 0x8, + Ping = 0x9, + Pong = 0xA, +} + +/// +/// Minimal RFC 6455 building blocks for the server side: the upgrade handshake and frame +/// encode/decode over a . Kept free of sockets so the protocol logic is +/// unit-testable; wires it to TCP. +/// +internal static class WebSocketProtocol +{ + private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + /// Computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key. + internal static string AcceptKey(string secWebSocketKey) + { + var bytes = Encoding.ASCII.GetBytes(secWebSocketKey + HandshakeGuid); + return Convert.ToBase64String(SHA1.HashData(bytes)); + } + + /// + /// Reads the HTTP upgrade request from (up to the blank line) + /// and extracts the Sec-WebSocket-Key header. Returns false on a malformed request. + /// + internal static bool TryReadHandshakeKey(Stream stream, out string key) + { + key = ""; + var buffer = new byte[8 * 1024]; + var length = 0; + // Читаем до конца заголовков (\r\n\r\n); запрос маленький, побайтовое чтение не больно. + while (length < buffer.Length) + { + var read = stream.Read(buffer, length, 1); + if (read == 0) + { + return false; + } + + length++; + if ( + length >= 4 + && buffer[length - 4] == (byte)'\r' + && buffer[length - 3] == (byte)'\n' + && buffer[length - 2] == (byte)'\r' + && buffer[length - 1] == (byte)'\n' + ) + { + break; + } + } + + var request = Encoding.ASCII.GetString(buffer, 0, length); + foreach (var line in request.Split("\r\n")) + { + var separator = line.IndexOf(':'); + if (separator < 0) + { + continue; + } + + if ( + line[..separator] + .Trim() + .Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase) + ) + { + key = line[(separator + 1)..].Trim(); + return key.Length > 0; + } + } + + return false; + } + + /// Writes the 101 Switching Protocols response completing the handshake. + internal static void WriteHandshakeResponse(Stream stream, string secWebSocketKey) + { + var response = + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {AcceptKey(secWebSocketKey)}\r\n" + + "\r\n"; + var bytes = Encoding.ASCII.GetBytes(response); + stream.Write(bytes, 0, bytes.Length); + } + + /// + /// Encodes one complete (FIN) frame. Server frames are unmasked per RFC 6455; + /// is for tests that emulate a client. + /// + internal static byte[] EncodeFrame( + ReadOnlySpan payload, + WebSocketOpcode opcode, + byte[]? maskKey = null + ) + { + var masked = maskKey is not null; + var headerLength = + 2 + + payload.Length switch + { + <= 125 => 0, + <= ushort.MaxValue => 2, + _ => 8, + }; + var frame = new byte[headerLength + (masked ? 4 : 0) + payload.Length]; + frame[0] = (byte)(0x80 | (byte)opcode); + switch (payload.Length) + { + case <= 125: + frame[1] = (byte)payload.Length; + break; + case <= ushort.MaxValue: + frame[1] = 126; + BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), (ushort)payload.Length); + break; + default: + frame[1] = 127; + BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(2), (ulong)payload.Length); + break; + } + + var offset = headerLength; + if (masked) + { + frame[1] |= 0x80; + maskKey!.CopyTo(frame, offset); + offset += 4; + for (var i = 0; i < payload.Length; i++) + { + frame[offset + i] = (byte)(payload[i] ^ maskKey[i % 4]); + } + } + else + { + payload.CopyTo(frame.AsSpan(offset)); + } + + return frame; + } + + /// + /// Reads one frame. Returns false on a clean end of stream. Masked payloads are unmasked. + /// + internal static bool TryReadFrame( + Stream stream, + out WebSocketOpcode opcode, + out bool fin, + out byte[] payload + ) + { + opcode = WebSocketOpcode.Close; + fin = true; + payload = []; + + var header = new byte[2]; + if (!TryReadExactly(stream, header)) + { + return false; + } + + fin = (header[0] & 0x80) != 0; + opcode = (WebSocketOpcode)(header[0] & 0x0F); + var masked = (header[1] & 0x80) != 0; + long length = header[1] & 0x7F; + if (length == 126) + { + var extended = new byte[2]; + if (!TryReadExactly(stream, extended)) + { + return false; + } + + length = BinaryPrimitives.ReadUInt16BigEndian(extended); + } + else if (length == 127) + { + var extended = new byte[8]; + if (!TryReadExactly(stream, extended)) + { + return false; + } + + length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended); + } + + if (length > MaxPayloadBytes) + { + return false; // защита от злонамеренной длины — соединение закроется + } + + var maskKey = new byte[4]; + if (masked && !TryReadExactly(stream, maskKey)) + { + return false; + } + + payload = new byte[length]; + if (!TryReadExactly(stream, payload)) + { + return false; + } + + if (masked) + { + for (var i = 0; i < payload.Length; i++) + { + payload[i] ^= maskKey[i % 4]; + } + } + + return true; + } + + /// Upper bound for a single frame payload accepted by the server. + internal const int MaxPayloadBytes = 16 * 1024 * 1024; + + private static bool TryReadExactly(Stream stream, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = stream.Read(buffer, offset, buffer.Length - offset); + if (read == 0) + { + return false; + } + + offset += read; + } + + return true; + } +} diff --git a/src/MrGameEng.Net/WebSocketServer.cs b/src/MrGameEng.Net/WebSocketServer.cs new file mode 100644 index 0000000..725adb7 --- /dev/null +++ b/src/MrGameEng.Net/WebSocketServer.cs @@ -0,0 +1,279 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using MrGameEng.Core; + +namespace MrGameEng.Net; + +/// +/// A dependency-free WebSocket server (RFC 6455 over ) for +/// dedicated servers. Accepting and reading happen on background tasks; the simulation +/// drains new connections with and reads messages by +/// polling each connection — nothing here touches the ECS world from another thread. +/// Binary messages only; pings are answered automatically. +/// +public sealed class WebSocketServer : IDisposable +{ + /// The port the server listens on. + public int Port { get; } + + /// Snapshot of currently open connections. + public IReadOnlyList Connections + { + get + { + lock (_connections) + { + return _connections.Where(c => c.IsOpen).Cast().ToArray(); + } + } + } + + private readonly TcpListener _listener; + private readonly List _connections = []; + private readonly ConcurrentQueue _accepted = new(); + private readonly CancellationTokenSource _shutdown = new(); + private int _nextConnectionId; + private bool _started; + + /// Creates a server for on all interfaces. Call to listen. + public WebSocketServer(int port) + { + Port = port; + _listener = new TcpListener(IPAddress.Any, port); + } + + /// Starts listening and accepting connections in the background. + public void Start() + { + if (_started) + { + return; + } + + _started = true; + _listener.Start(); + Task.Run(AcceptLoop); + Log.Info($"WebSocketServer listening on port {Port}"); + } + + /// Dequeues a connection that completed its handshake since the last call. + public bool TryAcceptConnection(out INetConnection connection) + { + if (_accepted.TryDequeue(out var accepted)) + { + connection = accepted; + return true; + } + + connection = null!; + return false; + } + + /// Stops listening and closes every connection. + public void Dispose() + { + _shutdown.Cancel(); + _listener.Stop(); + lock (_connections) + { + foreach (var connection in _connections) + { + connection.Close(); + } + + _connections.Clear(); + } + } + + private async Task AcceptLoop() + { + while (!_shutdown.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_shutdown.Token); + } + catch (Exception) when (_shutdown.IsCancellationRequested) + { + return; + } + catch (Exception exception) + { + Log.Warning($"WebSocketServer accept failed: {exception.Message}"); + continue; + } + + _ = Task.Run(() => Handshake(client)); + } + } + + private void Handshake(TcpClient client) + { + try + { + client.NoDelay = true; + var stream = client.GetStream(); + if (!WebSocketProtocol.TryReadHandshakeKey(stream, out var key)) + { + client.Dispose(); + return; + } + + WebSocketProtocol.WriteHandshakeResponse(stream, key); + var connection = new ServerConnection( + Interlocked.Increment(ref _nextConnectionId), + client + ); + lock (_connections) + { + _connections.RemoveAll(c => !c.IsOpen); + _connections.Add(connection); + } + + _accepted.Enqueue(connection); + connection.StartReceiveLoop(); + Log.Info($"WebSocketServer: connection #{connection.Id} accepted"); + } + catch (Exception exception) + { + Log.Warning($"WebSocketServer handshake failed: {exception.Message}"); + client.Dispose(); + } + } + + private sealed class ServerConnection : INetConnection + { + public int Id { get; } + public bool IsOpen => !_closed; + + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private readonly ConcurrentQueue _inbox = new(); + private readonly object _sendLock = new(); + private volatile bool _closed; + + internal ServerConnection(int id, TcpClient client) + { + Id = id; + _client = client; + _stream = client.GetStream(); + } + + internal void StartReceiveLoop() => Task.Run(ReceiveLoop); + + public void Send(ReadOnlySpan message) + { + if (_closed) + { + return; + } + + var frame = WebSocketProtocol.EncodeFrame(message, WebSocketOpcode.Binary); + try + { + lock (_sendLock) + { + _stream.Write(frame, 0, frame.Length); + } + } + catch (Exception) + { + Close(); + } + } + + public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!); + + public void Close() + { + if (_closed) + { + return; + } + + _closed = true; + try + { + _client.Dispose(); + } + catch (Exception) + { + // соединение уже мертво — закрытие не должно бросать + } + } + + private void ReceiveLoop() + { + var pending = new List(); + var pendingOpcode = WebSocketOpcode.Binary; + try + { + while (!_closed) + { + if ( + !WebSocketProtocol.TryReadFrame( + _stream, + out var opcode, + out var fin, + out var payload + ) + ) + { + break; + } + + switch (opcode) + { + case WebSocketOpcode.Ping: + lock (_sendLock) + { + var pong = WebSocketProtocol.EncodeFrame( + payload, + WebSocketOpcode.Pong + ); + _stream.Write(pong, 0, pong.Length); + } + + continue; + case WebSocketOpcode.Pong: + continue; + case WebSocketOpcode.Close: + lock (_sendLock) + { + var close = WebSocketProtocol.EncodeFrame( + [], + WebSocketOpcode.Close + ); + _stream.Write(close, 0, close.Length); + } + + return; + } + + if (opcode != WebSocketOpcode.Continuation) + { + pendingOpcode = opcode; + pending.Clear(); + } + + pending.AddRange(payload); + if (fin && pendingOpcode == WebSocketOpcode.Binary) + { + _inbox.Enqueue(pending.ToArray()); + pending.Clear(); + } + } + } + catch (Exception) + { + // обрыв соединения — штатный путь завершения цикла + } + finally + { + Close(); + } + } + } +} diff --git a/tests/MrGameEng.Net.Tests/MrGameEng.Net.Tests.csproj b/tests/MrGameEng.Net.Tests/MrGameEng.Net.Tests.csproj new file mode 100644 index 0000000..c6d4625 --- /dev/null +++ b/tests/MrGameEng.Net.Tests/MrGameEng.Net.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + Exe + false + + + + + + + + + + + + diff --git a/tests/MrGameEng.Net.Tests/ReplicationTests.cs b/tests/MrGameEng.Net.Tests/ReplicationTests.cs new file mode 100644 index 0000000..d5fce08 --- /dev/null +++ b/tests/MrGameEng.Net.Tests/ReplicationTests.cs @@ -0,0 +1,200 @@ +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) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth. + Assert.Equal(22, 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); + } + + 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."); + } +} diff --git a/tests/MrGameEng.Net.Tests/WebSocketLoopbackTests.cs b/tests/MrGameEng.Net.Tests/WebSocketLoopbackTests.cs new file mode 100644 index 0000000..c5fe165 --- /dev/null +++ b/tests/MrGameEng.Net.Tests/WebSocketLoopbackTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Sockets; +using MrGameEng.Net; +using Xunit; + +namespace MrGameEng.Net.Tests; + +public class WebSocketLoopbackTests +{ + [Fact] + public async Task ClientAndServer_ExchangeBinaryMessages_OverLoopback() + { + var port = FreePort(); + using var server = new WebSocketServer(port); + server.Start(); + + using var client = await WebSocketClient.ConnectAsync( + new Uri($"ws://localhost:{port}/"), + new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token + ); + + var connection = await WaitFor( + () => server.TryAcceptConnection(out var c) ? c : null, + "server accept" + ); + + // Сервер → клиент. + connection.Send([1, 2, 3, 250]); + var received = await WaitFor( + () => client.TryReceive(out var m) ? m : null, + "client receive" + ); + Assert.Equal(new byte[] { 1, 2, 3, 250 }, received); + + // Клиент → сервер (ClientWebSocket маскирует кадры — сервер обязан размаскировать). + client.Send([9, 8, 7]); + var echoed = await WaitFor( + () => connection.TryReceive(out var m) ? m : null, + "server receive" + ); + Assert.Equal(new byte[] { 9, 8, 7 }, echoed); + + Assert.True(connection.IsOpen); + Assert.True(client.IsOpen); + } + + [Fact] + public async Task ClosingTheClient_ClosesTheServerConnection() + { + var port = FreePort(); + using var server = new WebSocketServer(port); + server.Start(); + + var client = await WebSocketClient.ConnectAsync( + new Uri($"ws://localhost:{port}/"), + new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token + ); + var connection = await WaitFor( + () => server.TryAcceptConnection(out var c) ? c : null, + "server accept" + ); + + client.Close(); + + await WaitFor(() => connection.IsOpen ? null : "closed", "server-side close"); + Assert.False(connection.IsOpen); + } + + private static async Task WaitFor(Func poll, string what) + where T : class + { + for (var i = 0; i < 200; i++) + { + if (poll() is { } result) + { + return result; + } + + await Task.Delay(25); + } + + throw new TimeoutException($"Timed out waiting for {what}."); + } + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/tests/MrGameEng.Net.Tests/WebSocketProtocolTests.cs b/tests/MrGameEng.Net.Tests/WebSocketProtocolTests.cs new file mode 100644 index 0000000..7fbf79e --- /dev/null +++ b/tests/MrGameEng.Net.Tests/WebSocketProtocolTests.cs @@ -0,0 +1,88 @@ +using MrGameEng.Net; +using Xunit; + +namespace MrGameEng.Net.Tests; + +public class WebSocketProtocolTests +{ + [Fact] + public void AcceptKey_MatchesRfc6455Example() + { + // Пример рукопожатия прямо из RFC 6455, раздел 1.3. + Assert.Equal( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + WebSocketProtocol.AcceptKey("dGhlIHNhbXBsZSBub25jZQ==") + ); + } + + [Theory] + [InlineData(0)] + [InlineData(125)] + [InlineData(126)] + [InlineData(70_000)] + public void Frame_Roundtrips_Unmasked(int payloadLength) + { + var payload = MakePayload(payloadLength); + var frame = WebSocketProtocol.EncodeFrame(payload, WebSocketOpcode.Binary); + + using var stream = new MemoryStream(frame); + Assert.True( + WebSocketProtocol.TryReadFrame(stream, out var opcode, out var fin, out var decoded) + ); + Assert.Equal(WebSocketOpcode.Binary, opcode); + Assert.True(fin); + Assert.Equal(payload, decoded); + } + + [Fact] + public void Frame_Roundtrips_Masked() + { + var payload = MakePayload(1000); + var frame = WebSocketProtocol.EncodeFrame( + payload, + WebSocketOpcode.Binary, + maskKey: [0x12, 0x34, 0x56, 0x78] + ); + + using var stream = new MemoryStream(frame); + Assert.True(WebSocketProtocol.TryReadFrame(stream, out _, out _, out var decoded)); + Assert.Equal(payload, decoded); + } + + [Fact] + public void TryReadHandshakeKey_ExtractsHeader() + { + var request = + "GET /chat HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "\r\n"; + using var stream = new MemoryStream(System.Text.Encoding.ASCII.GetBytes(request)); + + Assert.True(WebSocketProtocol.TryReadHandshakeKey(stream, out var key)); + Assert.Equal("dGhlIHNhbXBsZSBub25jZQ==", key); + } + + [Fact] + public void TryReadFrame_TruncatedStream_ReturnsFalse() + { + var frame = WebSocketProtocol.EncodeFrame(MakePayload(100), WebSocketOpcode.Binary); + using var stream = new MemoryStream(frame, 0, frame.Length - 10); + + Assert.False(WebSocketProtocol.TryReadFrame(stream, out _, out _, out _)); + } + + private static byte[] MakePayload(int length) + { + var payload = new byte[length]; + for (var i = 0; i < length; i++) + { + payload[i] = (byte)(i * 31); + } + + return payload; + } +}