Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
CI / build-test (push) Successful in 1m16s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
10898b08a0
commit
3438ed77f6
@@ -64,6 +64,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
|
|||||||
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
||||||
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
||||||
→ `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
|
→ `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()`),
|
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
|
||||||
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
||||||
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGam
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -279,5 +307,7 @@ Global
|
|||||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
|
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
|
||||||
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
|
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **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<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
|
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `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.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) |
|
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
```
|
```
|
||||||
MrGameEng.Host ─┐
|
MrGameEng.Host ─┐
|
||||||
MrGameEng.Audio ─┤
|
MrGameEng.Audio ─┤
|
||||||
|
MrGameEng.Net ─┤
|
||||||
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
|
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
|
||||||
│
|
│
|
||||||
MrGameEng.Content ─┤
|
MrGameEng.Content ─┤
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="TryReceive"/> from the tick. Sending
|
||||||
|
/// never blocks the caller. Implementations are safe to use from one simulation thread.
|
||||||
|
/// </summary>
|
||||||
|
public interface INetConnection
|
||||||
|
{
|
||||||
|
/// <summary>Connection id, unique within its owner (server-assigned; 0 for a client's own connection).</summary>
|
||||||
|
int Id { get; }
|
||||||
|
|
||||||
|
/// <summary>False once the peer disconnected or the connection failed; sends become no-ops.</summary>
|
||||||
|
bool IsOpen { get; }
|
||||||
|
|
||||||
|
/// <summary>Queues one binary message for delivery. No-op when the connection is closed.</summary>
|
||||||
|
void Send(ReadOnlySpan<byte> message);
|
||||||
|
|
||||||
|
/// <summary>Dequeues the next received binary message, if any.</summary>
|
||||||
|
bool TryReceive(out byte[] message);
|
||||||
|
|
||||||
|
/// <summary>Closes the connection.</summary>
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Friflo.Engine.ECS" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Net.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks an entity as replicated and identifies it across the network. The server assigns
|
||||||
|
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
|
||||||
|
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
|
||||||
|
/// </summary>
|
||||||
|
public struct NetId : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>Network-wide entity id, unique per server world.</summary>
|
||||||
|
public int Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client side of replication: applies snapshot messages from a
|
||||||
|
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
|
||||||
|
/// spawn local entities (carrying <see cref="NetId"/>), 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 <see cref="ReplicationSchema"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReplicationClient
|
||||||
|
{
|
||||||
|
/// <summary>Number of replicated entities currently alive locally.</summary>
|
||||||
|
public int EntityCount => _entities.Count;
|
||||||
|
|
||||||
|
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
|
||||||
|
public event Action<Entity>? EntitySpawned;
|
||||||
|
|
||||||
|
private readonly ReplicationSchema _schema;
|
||||||
|
private readonly EntityStore _store;
|
||||||
|
private readonly Dictionary<int, Entity> _entities = [];
|
||||||
|
|
||||||
|
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
|
||||||
|
public ReplicationClient(ReplicationSchema schema, EntityStore store)
|
||||||
|
{
|
||||||
|
_schema = schema;
|
||||||
|
_store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
|
||||||
|
public void Pump(INetConnection connection)
|
||||||
|
{
|
||||||
|
while (connection.TryReceive(out var message))
|
||||||
|
{
|
||||||
|
Apply(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies one snapshot message to the local store.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Friflo.Engine.ECS;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReplicationSchema
|
||||||
|
{
|
||||||
|
internal sealed class ComponentSlot
|
||||||
|
{
|
||||||
|
public required int Bit;
|
||||||
|
public required int Size;
|
||||||
|
public required Func<Entity, byte[], bool> TryWrite;
|
||||||
|
public required Action<Entity, byte[], int> Apply;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly List<ComponentSlot> Slots = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers component type <typeparamref name="T"/> for replication. Returns this
|
||||||
|
/// schema for fluent chaining.
|
||||||
|
/// </summary>
|
||||||
|
public ReplicationSchema Register<T>()
|
||||||
|
where T : unmanaged, IComponent
|
||||||
|
{
|
||||||
|
if (Slots.Count == 32)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"ReplicationSchema supports at most 32 component types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = Unsafe.SizeOf<T>();
|
||||||
|
Slots.Add(
|
||||||
|
new ComponentSlot
|
||||||
|
{
|
||||||
|
Bit = Slots.Count,
|
||||||
|
Size = size,
|
||||||
|
TryWrite = (entity, buffer) =>
|
||||||
|
{
|
||||||
|
if (!entity.HasComponent<T>())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
Apply = (entity, data, offset) =>
|
||||||
|
{
|
||||||
|
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
|
||||||
|
entity.AddComponent(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
|
||||||
|
/// every entity carrying <see cref="NetId"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReplicationServer
|
||||||
|
{
|
||||||
|
private sealed class ConnectionState
|
||||||
|
{
|
||||||
|
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
|
||||||
|
public readonly Dictionary<int, byte[]?[]> LastSent = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ReplicationSchema _schema;
|
||||||
|
private readonly ArchetypeQuery<NetId> _query;
|
||||||
|
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
|
||||||
|
|
||||||
|
// Снапшот текущего тика, переиспользуется между соединениями.
|
||||||
|
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
|
||||||
|
private readonly HashSet<int> _currentIds = [];
|
||||||
|
private int _nextNetId;
|
||||||
|
|
||||||
|
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
|
||||||
|
public ReplicationServer(ReplicationSchema schema, EntityStore store)
|
||||||
|
{
|
||||||
|
_schema = schema;
|
||||||
|
_query = store.Query<NetId>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
|
||||||
|
public int NextNetId() => ++_nextNetId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshots the world once and sends per-connection deltas. Closed connections are
|
||||||
|
/// forgotten; brand-new ones receive the full state.
|
||||||
|
/// </summary>
|
||||||
|
public void Send(IReadOnlyList<INetConnection> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
|
||||||
|
internal static class ReplicationMessage
|
||||||
|
{
|
||||||
|
internal const byte Snapshot = 1;
|
||||||
|
internal const byte OpUpsert = 0;
|
||||||
|
internal const byte OpDespawn = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client side of <see cref="INetConnection"/>: a thin wrapper over the BCL
|
||||||
|
/// <see cref="ClientWebSocket"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WebSocketClient : INetConnection, IDisposable
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Id => 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsOpen => !_closed && _socket.State == WebSocketState.Open;
|
||||||
|
|
||||||
|
private readonly ClientWebSocket _socket;
|
||||||
|
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||||
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
|
private Task _sendTail = Task.CompletedTask;
|
||||||
|
private volatile bool _closed;
|
||||||
|
|
||||||
|
private WebSocketClient(ClientWebSocket socket) => _socket = socket;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to <paramref name="uri"/> (ws:// or wss://) and starts the receive loop.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<WebSocketClient> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Send(ReadOnlySpan<byte> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (_closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closed = true;
|
||||||
|
_shutdown.Cancel();
|
||||||
|
_socket.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the connection.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>WebSocket frame opcodes used by the server.</summary>
|
||||||
|
internal enum WebSocketOpcode : byte
|
||||||
|
{
|
||||||
|
Continuation = 0x0,
|
||||||
|
Text = 0x1,
|
||||||
|
Binary = 0x2,
|
||||||
|
Close = 0x8,
|
||||||
|
Ping = 0x9,
|
||||||
|
Pong = 0xA,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal RFC 6455 building blocks for the server side: the upgrade handshake and frame
|
||||||
|
/// encode/decode over a <see cref="Stream"/>. Kept free of sockets so the protocol logic is
|
||||||
|
/// unit-testable; <see cref="WebSocketServer"/> wires it to TCP.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WebSocketProtocol
|
||||||
|
{
|
||||||
|
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||||
|
|
||||||
|
/// <summary>Computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key.</summary>
|
||||||
|
internal static string AcceptKey(string secWebSocketKey)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.ASCII.GetBytes(secWebSocketKey + HandshakeGuid);
|
||||||
|
return Convert.ToBase64String(SHA1.HashData(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the HTTP upgrade request from <paramref name="stream"/> (up to the blank line)
|
||||||
|
/// and extracts the Sec-WebSocket-Key header. Returns false on a malformed request.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the 101 Switching Protocols response completing the handshake.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes one complete (FIN) frame. Server frames are unmasked per RFC 6455;
|
||||||
|
/// <paramref name="maskKey"/> is for tests that emulate a client.
|
||||||
|
/// </summary>
|
||||||
|
internal static byte[] EncodeFrame(
|
||||||
|
ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one frame. Returns false on a clean end of stream. Masked payloads are unmasked.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Upper bound for a single frame payload accepted by the server.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A dependency-free WebSocket server (RFC 6455 over <see cref="TcpListener"/>) for
|
||||||
|
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
|
||||||
|
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
|
||||||
|
/// polling each connection — nothing here touches the ECS world from another thread.
|
||||||
|
/// Binary messages only; pings are answered automatically.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WebSocketServer : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The port the server listens on.</summary>
|
||||||
|
public int Port { get; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of currently open connections.</summary>
|
||||||
|
public IReadOnlyList<INetConnection> Connections
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_connections)
|
||||||
|
{
|
||||||
|
return _connections.Where(c => c.IsOpen).Cast<INetConnection>().ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TcpListener _listener;
|
||||||
|
private readonly List<ServerConnection> _connections = [];
|
||||||
|
private readonly ConcurrentQueue<ServerConnection> _accepted = new();
|
||||||
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
|
private int _nextConnectionId;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
/// <summary>Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/> to listen.</summary>
|
||||||
|
public WebSocketServer(int port)
|
||||||
|
{
|
||||||
|
Port = port;
|
||||||
|
_listener = new TcpListener(IPAddress.Any, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts listening and accepting connections in the background.</summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
_listener.Start();
|
||||||
|
Task.Run(AcceptLoop);
|
||||||
|
Log.Info($"WebSocketServer listening on port {Port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dequeues a connection that completed its handshake since the last call.</summary>
|
||||||
|
public bool TryAcceptConnection(out INetConnection connection)
|
||||||
|
{
|
||||||
|
if (_accepted.TryDequeue(out var accepted))
|
||||||
|
{
|
||||||
|
connection = accepted;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops listening and closes every connection.</summary>
|
||||||
|
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<byte[]> _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<byte> 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<byte>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Net\MrGameEng.Net.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<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) + 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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> WaitFor<T>(Func<T?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user