Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Server.Auth;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class AuthWsTests
|
||||
{
|
||||
static string GamesRoot() => System.IO.Path.Combine(AppContext.BaseDirectory, "TestGames");
|
||||
|
||||
[Fact]
|
||||
public async Task Ws_with_valid_token_connects()
|
||||
{
|
||||
var key = new byte[32];
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(GamesRoot(), 100, authSecret: key,
|
||||
authDbPath: "Data Source=wsok_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
|
||||
try
|
||||
{
|
||||
string token = new TokenService(key).Issue(1234, TimeSpan.FromHours(1));
|
||||
using var ws = new ClientWebSocket();
|
||||
var uri = new Uri(host.WsBaseUrl + "/ws?token=" + token);
|
||||
await ws.ConnectAsync(uri, CancellationToken.None); // 不抛即握手成功
|
||||
Assert.Equal(WebSocketState.Open, ws.State);
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ws_with_invalid_token_rejected()
|
||||
{
|
||||
var key = new byte[32];
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(GamesRoot(), 100, authSecret: key,
|
||||
authDbPath: "Data Source=wsbad_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
|
||||
try
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
var uri = new Uri(host.WsBaseUrl + "/ws?token=garbage");
|
||||
await Assert.ThrowsAnyAsync<WebSocketException>(() => ws.ConnectAsync(uri, CancellationToken.None));
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class DevDiscoveryResponderTests
|
||||
{
|
||||
private static async Task<DevDiscoveryReply?> QueryAsync(int port, string token, uint nonce, int timeoutMs)
|
||||
{
|
||||
using var client = new UdpClient(AddressFamily.InterNetwork);
|
||||
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
|
||||
{
|
||||
ProtoVersion = DevDiscovery.ProtoVersion,
|
||||
Nonce = nonce,
|
||||
Token = token,
|
||||
});
|
||||
await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, port));
|
||||
|
||||
var recv = client.ReceiveAsync();
|
||||
var done = await Task.WhenAny(recv, Task.Delay(timeoutMs));
|
||||
if (done != recv) return null;
|
||||
UdpReceiveResult result = await recv;
|
||||
if (!DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply)) return null;
|
||||
return reply;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidQuery_ReceivesReply_WithAdvertisedAddresses()
|
||||
{
|
||||
var responder = new DevDiscoveryResponder(
|
||||
"dev-pc", "ws://192.168.1.23:5005/ws", "http://192.168.1.23:15081/minigame/",
|
||||
token: "secret", listenPort: 0);
|
||||
responder.Start();
|
||||
try
|
||||
{
|
||||
var reply = await QueryAsync(responder.Port, "secret", nonce: 7, timeoutMs: 2000);
|
||||
Assert.NotNull(reply);
|
||||
Assert.Equal("ws://192.168.1.23:5005/ws", reply.Value.GatewayUrl);
|
||||
Assert.Equal("http://192.168.1.23:15081/minigame/", reply.Value.ResourceBaseUrl);
|
||||
Assert.Equal("dev-pc", reply.Value.ServerName);
|
||||
Assert.Equal(7u, reply.Value.Nonce);
|
||||
}
|
||||
finally { await responder.StopAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongToken_NoReply()
|
||||
{
|
||||
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
|
||||
responder.Start();
|
||||
try
|
||||
{
|
||||
var reply = await QueryAsync(responder.Port, "WRONG", nonce: 1, timeoutMs: 800);
|
||||
Assert.Null(reply);
|
||||
}
|
||||
finally { await responder.StopAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GarbagePacket_Ignored_LoopStillAnswers()
|
||||
{
|
||||
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
|
||||
responder.Start();
|
||||
try
|
||||
{
|
||||
using (var raw = new UdpClient(AddressFamily.InterNetwork))
|
||||
{
|
||||
byte[] junk = { 1, 2, 3 };
|
||||
await raw.SendAsync(junk, junk.Length, new IPEndPoint(IPAddress.Loopback, responder.Port));
|
||||
}
|
||||
var reply = await QueryAsync(responder.Port, "secret", nonce: 9, timeoutMs: 2000);
|
||||
Assert.NotNull(reply);
|
||||
Assert.Equal(9u, reply.Value.Nonce);
|
||||
}
|
||||
finally { await responder.StopAsync(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class GameServerHostDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HostWithoutToken_NoResponder()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 50);
|
||||
try { Assert.Equal(0, host.DevDiscoveryPort); }
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostWithToken_AnswersDiscovery_AdvertisesWsAndCdn()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 50,
|
||||
devDiscoveryToken: "secret",
|
||||
advertiseResourceBaseUrl: "http://192.168.1.50:15081/minigame/",
|
||||
devDiscoveryPort: 0);
|
||||
try
|
||||
{
|
||||
Assert.NotEqual(0, host.DevDiscoveryPort);
|
||||
|
||||
using var client = new UdpClient(AddressFamily.InterNetwork);
|
||||
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
|
||||
{
|
||||
ProtoVersion = DevDiscovery.ProtoVersion,
|
||||
Nonce = 3,
|
||||
Token = "secret",
|
||||
});
|
||||
await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, host.DevDiscoveryPort));
|
||||
|
||||
var recv = client.ReceiveAsync();
|
||||
var done = await Task.WhenAny(recv, Task.Delay(2000));
|
||||
Assert.Same(recv, done);
|
||||
UdpReceiveResult result = await recv;
|
||||
Assert.True(DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply));
|
||||
Assert.Equal(host.WsBaseUrl + "/ws", reply.GatewayUrl);
|
||||
Assert.Equal("http://192.168.1.50:15081/minigame/", reply.ResourceBaseUrl);
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Gateway\Gateway.csproj" />
|
||||
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
|
||||
<Content Include="..\Server.Host.Tests\TestGames\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class GatewayEndToEndTests
|
||||
{
|
||||
private static Frame JoinRps()
|
||||
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
|
||||
|
||||
[Fact]
|
||||
public async Task Heartbeat_IsEchoedBack()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
|
||||
try
|
||||
{
|
||||
using var client = new WsTestClient();
|
||||
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
|
||||
await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
|
||||
new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));
|
||||
|
||||
Frame? echo = null;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var f = await client.ReceiveFrameAsync(5000);
|
||||
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
|
||||
}
|
||||
Assert.True(echo.HasValue, "未收到心跳回显");
|
||||
Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
|
||||
await client.CloseAsync();
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_ContinuesReceivingFromSameRoom()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
// matchTimeoutTicks=3 → 单人后 AI 兜底成团;logicalDt=0.2 让 60s 对局慢跑;大重连窗口
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 200,
|
||||
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 0.2f);
|
||||
try
|
||||
{
|
||||
var client1 = new WsTestClient();
|
||||
await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
|
||||
await client1.SendFrameAsync(JoinRps());
|
||||
|
||||
// 收到 MatchFound 后的第一个游戏快照
|
||||
bool gotSnapshot = false;
|
||||
for (int i = 0; i < 15 && !gotSnapshot; i++)
|
||||
{
|
||||
var f = await client1.ReceiveFrameAsync(5000);
|
||||
if (f.Channel == Channel.Game) gotSnapshot = true;
|
||||
}
|
||||
Assert.True(gotSnapshot, "重连前应先收到一个快照");
|
||||
|
||||
// 断开
|
||||
await client1.CloseAsync();
|
||||
client1.Dispose();
|
||||
|
||||
// 用同 pid 重连
|
||||
using var client2 = new WsTestClient();
|
||||
await client2.ConnectAsync(host.WsBaseUrl, pid: 42);
|
||||
|
||||
// 房间仍在跑,下一个房间输出应发到 client2
|
||||
bool gotAfterReconnect = false;
|
||||
for (int i = 0; i < 15 && !gotAfterReconnect; i++)
|
||||
{
|
||||
var f = await client2.ReceiveFrameAsync(5000);
|
||||
if (f.Channel == Channel.Game) gotAfterReconnect = true;
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) gotAfterReconnect = true;
|
||||
}
|
||||
Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
|
||||
await client2.CloseAsync();
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Xunit;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class MatchmakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void TwoEnqueues_SameBucket_PlayerCount2_Poll_ReturnsTwoHumanMatch()
|
||||
{
|
||||
var mm = new Matchmaker(timeoutTicks: 100);
|
||||
mm.Enqueue(pid: 1, gameId: "rps", version: 1, playerCount: 2, tick: 0);
|
||||
mm.Enqueue(pid: 2, gameId: "rps", version: 1, playerCount: 2, tick: 0);
|
||||
|
||||
var matches = mm.Poll(currentTick: 0);
|
||||
|
||||
Assert.Single(matches);
|
||||
var m = matches[0];
|
||||
Assert.Equal("rps", m.GameId);
|
||||
Assert.Equal(1, m.Version);
|
||||
Assert.Equal(2, m.Seats.Count);
|
||||
Assert.False(m.Seats[0].IsAi);
|
||||
Assert.False(m.Seats[1].IsAi);
|
||||
Assert.Equal(1, m.Seats[0].PlayerId);
|
||||
Assert.Equal(2, m.Seats[1].PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleEnqueue_PollAfterTimeout_Returns1Human1AiMatch()
|
||||
{
|
||||
var mm = new Matchmaker(timeoutTicks: 10);
|
||||
mm.Enqueue(pid: 7, gameId: "rps", version: 1, playerCount: 2, tick: 0);
|
||||
|
||||
// Not timed out yet
|
||||
var early = mm.Poll(currentTick: 9);
|
||||
Assert.Empty(early);
|
||||
|
||||
// Now timed out (currentTick - enqueuedTick > timeoutTicks → 11 - 0 = 11 > 10)
|
||||
var matches = mm.Poll(currentTick: 10);
|
||||
|
||||
Assert.Single(matches);
|
||||
var m = matches[0];
|
||||
Assert.Equal(2, m.Seats.Count);
|
||||
// First seat is the real player
|
||||
Assert.False(m.Seats[0].IsAi);
|
||||
Assert.Equal(7, m.Seats[0].PlayerId);
|
||||
// Second seat is AI with negative pid
|
||||
Assert.True(m.Seats[1].IsAi);
|
||||
Assert.True(m.Seats[1].PlayerId < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotFull_NotTimedOut_Poll_ReturnsEmpty()
|
||||
{
|
||||
var mm = new Matchmaker(timeoutTicks: 100);
|
||||
mm.Enqueue(pid: 3, gameId: "rps", version: 1, playerCount: 2, tick: 5);
|
||||
|
||||
var matches = mm.Poll(currentTick: 50); // 50-5=45 <= 100, not timed out, not full
|
||||
|
||||
Assert.Empty(matches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch()
|
||||
{
|
||||
// playerCount=1 应立即成团(Matchmaker 仅按 playerCount 分桶,不加载具体游戏)
|
||||
var mm = new Matchmaker(timeoutTicks: 100);
|
||||
mm.Enqueue(pid: 5, gameId: "solo1p", version: 1, playerCount: 1, tick: 0);
|
||||
|
||||
var matches = mm.Poll(currentTick: 0);
|
||||
|
||||
Assert.Single(matches);
|
||||
Assert.Single(matches[0].Seats);
|
||||
Assert.False(matches[0].Seats[0].IsAi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_DequeuesWaiter_SoNoMatchForms()
|
||||
{
|
||||
var mm = new Matchmaker(timeoutTicks: 100);
|
||||
mm.Enqueue(1, "rps", 1, 2, tick: 0);
|
||||
Assert.True(mm.Remove(1));
|
||||
mm.Enqueue(2, "rps", 1, 2, tick: 0); // 只剩玩家2,凑不齐2人
|
||||
Assert.Empty(mm.Poll(1)); // 未满未超时 → 无对局
|
||||
Assert.False(mm.Remove(999)); // 不存在的 pid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class RoomOutputSinkTests
|
||||
{
|
||||
[Fact]
|
||||
public void Broadcast_WrapsInGameChannelFrame_ToConnectedPlayers()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
sm.OnConnect(1, c1);
|
||||
sm.OnConnect(2, c2);
|
||||
|
||||
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
|
||||
sink.Broadcast(new NetMessage(7, new byte[] { 9, 9 }));
|
||||
|
||||
Assert.Single(c1.Sent);
|
||||
Assert.Single(c2.Sent);
|
||||
var f = FrameCodec.Decode(c1.Sent[0]);
|
||||
Assert.Equal(Channel.Game, f.Channel);
|
||||
Assert.Equal((ushort)7, f.Opcode);
|
||||
Assert.Equal(new byte[] { 9, 9 }, f.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Broadcast_SkipsDisconnectedPlayer()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
sm.OnConnect(1, c1);
|
||||
sm.OnConnect(2, new FakeConnection(2));
|
||||
sm.OnDisconnect(2, sm.GetConnection(2), 0); // 玩家2 离线
|
||||
|
||||
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
|
||||
sink.Broadcast(new NetMessage(1, new byte[0]));
|
||||
|
||||
Assert.Single(c1.Sent); // 仅在线玩家收到,不抛异常
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendTo_OnlyTargetPlayer()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
sm.OnConnect(1, c1);
|
||||
sm.OnConnect(2, c2);
|
||||
|
||||
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
|
||||
sink.SendTo(2, new NetMessage(5, new byte[] { 1 }));
|
||||
|
||||
Assert.Empty(c1.Sent);
|
||||
Assert.Single(c2.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Broadcast_AfterReconnect_GoesToNewConnection()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
sm.OnConnect(1, c1);
|
||||
var sink = new RoomOutputSink("r1", new List<int> { 1 }, sm);
|
||||
|
||||
sink.Broadcast(new NetMessage(1, new byte[] { 1 }));
|
||||
Assert.Single(c1.Sent);
|
||||
|
||||
// 断线后重连到新连接
|
||||
sm.OnDisconnect(1, c1, 0);
|
||||
var c2 = new FakeConnection(1);
|
||||
sm.OnConnect(1, c2);
|
||||
|
||||
sink.Broadcast(new NetMessage(1, new byte[] { 2 }));
|
||||
Assert.Single(c1.Sent); // 旧连接不再收到
|
||||
Assert.Single(c2.Sent); // 新连接收到
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class RpsEndToEndTests
|
||||
{
|
||||
// RPS ChoiceOpcode = 1 (same as SnapshotOpcode from RpsServerRoom)
|
||||
private const ushort RpsChoiceOpcode = 1;
|
||||
|
||||
private static Frame RpsJoinFrame()
|
||||
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
|
||||
|
||||
private static Frame RpsChoiceFrame(byte choice)
|
||||
{
|
||||
var w = new PacketWriter();
|
||||
w.WriteByte(choice);
|
||||
return new Frame(Channel.Game, RpsChoiceOpcode, w.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two real clients match → both receive MatchFound → play choices → both receive RoomEnd
|
||||
/// Uses large logicalDt so 60s RPS finishes in ~6 ticks (≈120ms wall-clock with 20ms interval)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TwoHumans_MatchAndPlayToRoomEnd()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
// matchTimeoutTicks=200 (generous, will fill by count=2)
|
||||
// logicalDt=11f: each tick advances 11 logical seconds → ~6 ticks > 60s
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 20,
|
||||
reconnectWindowTicks: 100, matchTimeoutTicks: 200, logicalDt: 11f);
|
||||
try
|
||||
{
|
||||
using var client1 = new WsTestClient();
|
||||
using var client2 = new WsTestClient();
|
||||
|
||||
await client1.ConnectAsync(host.WsBaseUrl, pid: 1);
|
||||
await client2.ConnectAsync(host.WsBaseUrl, pid: 2);
|
||||
|
||||
await client1.SendFrameAsync(RpsJoinFrame());
|
||||
await client2.SendFrameAsync(RpsJoinFrame());
|
||||
|
||||
// Both clients need to collect frames until MatchFound, then send choices, then wait for RoomEnd
|
||||
bool c1MatchFound = false, c2MatchFound = false;
|
||||
bool c1RoomEnd = false, c2RoomEnd = false;
|
||||
string c1RoomId = null, c2RoomId = null;
|
||||
RoomEndMsg c1End = null, c2End = null;
|
||||
|
||||
// Receive frames from both clients concurrently
|
||||
var t1 = Task.Run(async () =>
|
||||
{
|
||||
for (int i = 0; i < 60 && !c1RoomEnd; i++)
|
||||
{
|
||||
Frame f;
|
||||
try { f = await client1.ReceiveFrameAsync(3000); }
|
||||
catch (TimeoutException) { break; }
|
||||
catch (Exception) { break; }
|
||||
|
||||
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
|
||||
{
|
||||
c1MatchFound = true;
|
||||
var mf = MatchFoundMsg.Decode(f.Payload);
|
||||
c1RoomId = mf.RoomId;
|
||||
// Send Rock choice after matched
|
||||
await client1.SendFrameAsync(RpsChoiceFrame(1));
|
||||
}
|
||||
else if (f.Channel == Channel.Game)
|
||||
{
|
||||
// Got a snapshot; keep sending Rock in case we're in Choosing phase
|
||||
await client1.SendFrameAsync(RpsChoiceFrame(1));
|
||||
}
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
|
||||
{
|
||||
c1End = RoomEndMsg.Decode(f.Payload);
|
||||
c1RoomEnd = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var t2 = Task.Run(async () =>
|
||||
{
|
||||
for (int i = 0; i < 60 && !c2RoomEnd; i++)
|
||||
{
|
||||
Frame f;
|
||||
try { f = await client2.ReceiveFrameAsync(3000); }
|
||||
catch (TimeoutException) { break; }
|
||||
catch (Exception) { break; }
|
||||
|
||||
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
|
||||
{
|
||||
c2MatchFound = true;
|
||||
var mf = MatchFoundMsg.Decode(f.Payload);
|
||||
c2RoomId = mf.RoomId;
|
||||
await client2.SendFrameAsync(RpsChoiceFrame(2)); // Paper
|
||||
}
|
||||
else if (f.Channel == Channel.Game)
|
||||
{
|
||||
await client2.SendFrameAsync(RpsChoiceFrame(2)); // Paper
|
||||
}
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
|
||||
{
|
||||
c2End = RoomEndMsg.Decode(f.Payload);
|
||||
c2RoomEnd = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(t1, t2);
|
||||
|
||||
Assert.True(c1MatchFound, "client1 should receive MatchFound");
|
||||
Assert.True(c2MatchFound, "client2 should receive MatchFound");
|
||||
Assert.NotNull(c1RoomId);
|
||||
Assert.Equal(c1RoomId, c2RoomId); // Both in same room
|
||||
Assert.True(c1RoomEnd, "client1 should receive RoomEnd");
|
||||
Assert.True(c2RoomEnd, "client2 should receive RoomEnd");
|
||||
|
||||
// 结算结果:blob 非占位(RPS 填入 UTF-8 比分文本),双端一致
|
||||
Assert.NotNull(c1End);
|
||||
Assert.NotNull(c2End);
|
||||
Assert.True(c1End.ResultBlob != null && c1End.ResultBlob.Length > 0, "RoomEnd 应携带结算 blob");
|
||||
Assert.Equal(c1End.WinnerPlayerId, c2End.WinnerPlayerId);
|
||||
Assert.Equal(c1End.ResultBlob, c2End.ResultBlob); // 网关按房间统一下发同一结算
|
||||
Assert.Contains(":", System.Text.Encoding.UTF8.GetString(c1End.ResultBlob)); // 比分/平局文本
|
||||
|
||||
await client1.CloseAsync();
|
||||
await client2.CloseAsync();
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single client + small matchTimeoutTicks → AI fill → client receives MatchFound with AI player → RoomEnd
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SingleClient_AiFill_MatchAndPlayToRoomEnd()
|
||||
{
|
||||
var host = new GameServerHost();
|
||||
// matchTimeoutTicks=3: after ~3 ticks (60ms at 20ms interval), AI fills in
|
||||
// logicalDt=11f: large logical time step to finish RPS in ~6 ticks
|
||||
await host.StartAsync(TestGames.Root, tickIntervalMs: 20,
|
||||
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 11f);
|
||||
try
|
||||
{
|
||||
using var client = new WsTestClient();
|
||||
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
|
||||
await client.SendFrameAsync(RpsJoinFrame());
|
||||
|
||||
bool matchFound = false;
|
||||
bool hasAiPlayer = false;
|
||||
bool roomEnd = false;
|
||||
RoomEndMsg end = null;
|
||||
|
||||
for (int i = 0; i < 60 && !roomEnd; i++)
|
||||
{
|
||||
Frame f;
|
||||
try { f = await client.ReceiveFrameAsync(3000); }
|
||||
catch (TimeoutException) { break; }
|
||||
catch (Exception) { break; }
|
||||
|
||||
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
|
||||
{
|
||||
matchFound = true;
|
||||
var mf = MatchFoundMsg.Decode(f.Payload);
|
||||
Assert.Equal(2, mf.Players.Count);
|
||||
foreach (var p in mf.Players)
|
||||
if (p.IsAI) { hasAiPlayer = true; Assert.True(p.PlayerId < 0); }
|
||||
// Send Rock choice after matched
|
||||
await client.SendFrameAsync(RpsChoiceFrame(1));
|
||||
}
|
||||
else if (f.Channel == Channel.Game)
|
||||
{
|
||||
// Keep sending Rock on each snapshot
|
||||
await client.SendFrameAsync(RpsChoiceFrame(1));
|
||||
}
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
|
||||
{
|
||||
end = RoomEndMsg.Decode(f.Payload);
|
||||
roomEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(matchFound, "Should receive MatchFound (with AI fill)");
|
||||
Assert.True(hasAiPlayer, "MatchFound should include an AI player");
|
||||
Assert.True(roomEnd, "Should receive RoomEnd after AI-fill game settles");
|
||||
Assert.NotNull(end);
|
||||
Assert.True(end.ResultBlob != null && end.ResultBlob.Length > 0, "AI 对局的 RoomEnd 也应携带结算 blob");
|
||||
|
||||
await client.CloseAsync();
|
||||
}
|
||||
finally { await host.StopAsync(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Host;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class ServerLoopMatchmakingTests
|
||||
{
|
||||
// Helper: create a ServerLoop with small matchTimeout for predictable tests
|
||||
private static ServerLoop NewLoop(long matchTimeoutTicks = 5, long reconnectWindow = 100)
|
||||
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"),
|
||||
reconnectWindow, matchTimeoutTicks);
|
||||
|
||||
private static byte[] RpsJoinFrame()
|
||||
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode()));
|
||||
|
||||
private static Frame FrameworkFrame(FrameworkOpcode opcode, byte[] payload = null)
|
||||
=> new Frame(Channel.Framework, (ushort)opcode, payload ?? System.Array.Empty<byte>());
|
||||
|
||||
// Build a Game frame for RPS choice: opcode=1, payload=single byte (choice)
|
||||
private static Frame ChoiceFrame(byte choice)
|
||||
{
|
||||
var w = new PacketWriter();
|
||||
w.WriteByte(choice);
|
||||
return new Frame(Channel.Game, 1, w.ToArray());
|
||||
}
|
||||
|
||||
private static List<Frame> Frames(FakeConnection c)
|
||||
{
|
||||
var list = new List<Frame>();
|
||||
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool HasMatchFound(FakeConnection c) =>
|
||||
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
|
||||
|
||||
private static bool HasRoomEnd(FakeConnection c) =>
|
||||
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd);
|
||||
|
||||
private static MatchFoundMsg GetMatchFound(FakeConnection c)
|
||||
{
|
||||
var f = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
|
||||
return MatchFoundMsg.Decode(f.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lobby_GameList_AndRandomMatch_AssignsDiscoverableGame()
|
||||
{
|
||||
var loop = NewLoop(matchTimeoutTicks: 2, reconnectWindow: 100);
|
||||
var c = new FakeConnection(7);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 7, Connection = c });
|
||||
loop.Submit(new FrameCommand { PlayerId = 7, Frame = FrameworkFrame(FrameworkOpcode.GameListRequest) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
var listFrame = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.GameListResponse);
|
||||
var list = GameListResponseMsg.Decode(listFrame.Payload);
|
||||
// 两个 rps 夹具(v1/v2,同 IL)并存时,大厅只列出最新版本;具体版本号非本测试重点
|
||||
Assert.Contains(list.Games, g => g.GameId == "rps");
|
||||
int rpsVersion = 0;
|
||||
foreach (var g in list.Games) if (g.GameId == "rps") rpsVersion = g.Version;
|
||||
|
||||
loop.Submit(new FrameCommand { PlayerId = 7, Frame = FrameworkFrame(FrameworkOpcode.RandomMatchRequest) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
var assignedFrame = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchAssigned);
|
||||
var assigned = MatchAssignedMsg.Decode(assignedFrame.Payload);
|
||||
Assert.Equal("rps", assigned.GameId);
|
||||
Assert.Equal(rpsVersion, assigned.Version); // 随机匹配应分配大厅列出的(最新)rps 版本
|
||||
|
||||
loop.DrainAndTick(1f);
|
||||
loop.DrainAndTick(1f);
|
||||
loop.DrainAndTick(1f);
|
||||
Assert.True(HasMatchFound(c), "random match should AI-fill after timeout");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two clients send MatchRequest(rps,1) → they end up in the same room → game settles → both get RoomEnd
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TwoPlayers_Rps_MatchTogether_PlayToEnd()
|
||||
{
|
||||
var loop = NewLoop(matchTimeoutTicks: 100); // generous timeout so they form by count=2
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
|
||||
// Both connect and send MatchRequest
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
|
||||
// Tick once: matchmaker should see 2 players for rps → form match immediately (count >= playerCount)
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.True(HasMatchFound(c1), "c1 should get MatchFound");
|
||||
Assert.True(HasMatchFound(c2), "c2 should get MatchFound");
|
||||
|
||||
// Verify same roomId
|
||||
var mf1 = GetMatchFound(c1);
|
||||
var mf2 = GetMatchFound(c2);
|
||||
Assert.Equal(mf1.RoomId, mf2.RoomId);
|
||||
Assert.Equal(2, mf1.Players.Count);
|
||||
Assert.Equal(2, mf2.Players.Count);
|
||||
Assert.False(mf1.Players[0].IsAI);
|
||||
Assert.False(mf1.Players[1].IsAI);
|
||||
|
||||
// Both send Rock (choice=1) each tick; advance game with large dt to finish within ~6 ticks
|
||||
// RPS total = 60s, dt=11f per tick → 6 ticks covers 66s
|
||||
for (int i = 0; i < 10 && !(HasRoomEnd(c1) && HasRoomEnd(c2)); i++)
|
||||
{
|
||||
// Send Rock choices each round
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = ChoiceFrame(1) }); // Rock
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = ChoiceFrame(1) }); // Rock
|
||||
loop.DrainAndTick(11f); // large dt advances logical time fast
|
||||
}
|
||||
|
||||
Assert.True(HasRoomEnd(c1), "c1 should receive RoomEnd");
|
||||
Assert.True(HasRoomEnd(c2), "c2 should receive RoomEnd");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single player sends MatchRequest, timeout triggers AI fill → settles
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SinglePlayer_Rps_AiFillAfterTimeout_GameSettles()
|
||||
{
|
||||
// matchTimeoutTicks=2: after 2 ticks (currentTick-enqueuedTick > 2) AI fills in
|
||||
var loop = NewLoop(matchTimeoutTicks: 2, reconnectWindow: 100);
|
||||
var c1 = new FakeConnection(10);
|
||||
|
||||
loop.Submit(new ConnectCommand { PlayerId = 10, Connection = c1 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 10, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
|
||||
// DrainAndTick 1: enqueue pid=10 at tick=0; poll at tick=0 → 0-0=0 NOT > 2 → no match yet; tick++→1
|
||||
loop.DrainAndTick(1f);
|
||||
Assert.False(HasMatchFound(c1), "Should not match yet (not timed out)");
|
||||
|
||||
// DrainAndTick 2: poll at tick=1 → 1-0=1 NOT > 2 → no match; tick++→2
|
||||
loop.DrainAndTick(1f);
|
||||
Assert.False(HasMatchFound(c1), "Should not match yet (1 tick elapsed)");
|
||||
|
||||
// DrainAndTick 3: poll at tick=2 → 2-0=2 NOT > 2 → no match; tick++→3
|
||||
loop.DrainAndTick(1f);
|
||||
Assert.True(HasMatchFound(c1), "Should receive MatchFound with AI fill after timeout");
|
||||
|
||||
// DrainAndTick 4: poll at tick=3 → 3-0=3 > 2 → AI fill! Match formed; tick++→4
|
||||
loop.DrainAndTick(1f);
|
||||
Assert.True(HasMatchFound(c1), "Should receive MatchFound with AI fill after timeout");
|
||||
|
||||
var mf = GetMatchFound(c1);
|
||||
Assert.Equal(2, mf.Players.Count);
|
||||
// One real player, one AI
|
||||
int aiCount = 0, humanCount = 0;
|
||||
foreach (var p in mf.Players)
|
||||
{
|
||||
if (p.IsAI) { aiCount++; Assert.True(p.PlayerId < 0, "AI pid should be negative"); }
|
||||
else humanCount++;
|
||||
}
|
||||
Assert.Equal(1, aiCount);
|
||||
Assert.Equal(1, humanCount);
|
||||
|
||||
// Advance game to completion: AI auto-plays each tick
|
||||
for (int i = 0; i < 10 && !HasRoomEnd(c1); i++)
|
||||
{
|
||||
loop.Submit(new FrameCommand { PlayerId = 10, Frame = ChoiceFrame(1) }); // player sends Rock
|
||||
loop.DrainAndTick(11f); // large dt to advance logical time
|
||||
}
|
||||
Assert.True(HasRoomEnd(c1), "Should receive RoomEnd after AI match settles");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DuplicateMatchRequest while in queue should be rejected with Error
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DuplicateMatchRequest_WhileQueued_IsRejectedWithError()
|
||||
{
|
||||
var loop = NewLoop(matchTimeoutTicks: 100);
|
||||
var c = new FakeConnection(5);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 5, Connection = c });
|
||||
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.DrainAndTick(1f); // enqueued, but rps needs 2 players → still in queue
|
||||
|
||||
// Send second MatchRequest while still queued
|
||||
loop.Submit(new FrameCommand { PlayerId = 5, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.Contains(Frames(c), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player 1 disconnects while still queued (before match forms).
|
||||
/// DrainAndTick processes the DisconnectCommand (which dequeues pid=1) BEFORE Poll,
|
||||
/// so only player 2 remains in queue — count 1 < 2, not timed out → no match forms,
|
||||
/// and player 2 does NOT receive MatchFound.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QueuedPlayer_Disconnects_IsRemovedFromQueue_NoMatchFormsForRemaining()
|
||||
{
|
||||
// Large timeout so timeout-based AI fill doesn't trigger
|
||||
var loop = NewLoop(matchTimeoutTicks: 10000, reconnectWindow: 100);
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
|
||||
// Both connect and send MatchRequest — all submitted before any DrainAndTick,
|
||||
// so all will be processed in the command-drain pass of the first DrainAndTick.
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
|
||||
// Player 1 disconnects — also submitted before DrainAndTick.
|
||||
// DrainAndTick drains ALL commands first (including this disconnect → dequeues pid=1),
|
||||
// THEN calls Poll. So when Poll runs, only pid=2 is in the bucket (count=1 < 2).
|
||||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
|
||||
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
// No match should have formed: pid=1 was dequeued on disconnect, pid=2 alone < 2 players.
|
||||
Assert.False(HasMatchFound(c1), "Disconnected player 1 must not receive MatchFound");
|
||||
Assert.False(HasMatchFound(c2), "Player 2 alone in queue must not receive MatchFound (count 1 < 2, not timed out)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.PublishTool;
|
||||
using XWorld.Server.Host;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests: ServerLoop with publicKeyPem enforces signature verification end-to-end.
|
||||
/// TDD: these tests are written RED-first, then made GREEN by wiring publicKeyPem through
|
||||
/// ServerLoop ctor → GameModuleLoader.
|
||||
/// </summary>
|
||||
public class ServerLoopSignatureEnforcementTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
private readonly string _gameDir; // {tempRoot}/rps/1/
|
||||
|
||||
public ServerLoopSignatureEnforcementTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), "sig_enforcement_" + Guid.NewGuid().ToString("N"));
|
||||
_gameDir = Path.Combine(_tempRoot, "rps", "1");
|
||||
Directory.CreateDirectory(_gameDir);
|
||||
|
||||
// Copy rps/1 files from the shared TestGames root (built by build-test-games.sh)
|
||||
string src = Path.Combine(TestGames.Root, "rps", "1");
|
||||
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
|
||||
File.Copy(Path.Combine(src, name), Path.Combine(_gameDir, name), overwrite: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!Directory.Exists(_tempRoot)) return;
|
||||
// Unload ALCs and wait for Windows file locks to release
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
try { Directory.Delete(_tempRoot, true); }
|
||||
catch (UnauthorizedAccessException) { /* Windows file lock: GC will release eventually */ }
|
||||
}
|
||||
|
||||
private static (string pubPem, string privPem) GenerateKeyPair()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write files.txt + files.txt.sig into _gameDir using the given private key.
|
||||
/// </summary>
|
||||
private void WriteSignature(string privPem)
|
||||
{
|
||||
var fm = new FilesManifest();
|
||||
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
|
||||
{
|
||||
string p = Path.Combine(_gameDir, name);
|
||||
byte[] b = File.ReadAllBytes(p);
|
||||
fm.Add(name, Md5Util.OfBytes(b), b.Length);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
|
||||
File.WriteAllBytes(Path.Combine(_gameDir, "files.txt.sig"),
|
||||
ManifestSigner.Sign(fm.Digest(), privPem));
|
||||
}
|
||||
|
||||
// --- helpers mirroring ServerLoopMatchmakingTests ---
|
||||
|
||||
private static byte[] RpsJoinFrame()
|
||||
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode()));
|
||||
|
||||
private static List<Frame> Frames(FakeConnection c)
|
||||
{
|
||||
var list = new List<Frame>();
|
||||
foreach (var bytes in c.Sent)
|
||||
list.Add(FrameCodec.Decode(bytes));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool HasMatchFound(FakeConnection c) =>
|
||||
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound);
|
||||
|
||||
private static bool HasError(FakeConnection c) =>
|
||||
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Signed module + correct public key → 2 players receive MatchFound (verification passes).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SignedModule_WithCorrectKey_PlayersGetMatchFound()
|
||||
{
|
||||
var (pubPem, privPem) = GenerateKeyPair();
|
||||
WriteSignature(privPem);
|
||||
|
||||
// Large matchTimeoutTicks so the 2 humans match by player-count before any AI fill
|
||||
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-ok]"),
|
||||
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
|
||||
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
|
||||
// One DrainAndTick: both players enqueued → matchmaker sees count=2 >= playerCount=2
|
||||
// → forms match → CreateRoomForSeats → GameModuleLoader.Load (verifies signature: OK)
|
||||
// → room created → both players receive MatchFound
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.True(HasMatchFound(c1), "c1 should receive MatchFound when signature is valid");
|
||||
Assert.True(HasMatchFound(c2), "c2 should receive MatchFound when signature is valid");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tampered module + correct public key → signature verification fails during CreateRoom
|
||||
/// → CreateRoomForSeats catches and sends Error to players → neither gets MatchFound.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TamperedModule_WithKey_PlayersGetErrorNotMatchFound()
|
||||
{
|
||||
var (pubPem, privPem) = GenerateKeyPair();
|
||||
WriteSignature(privPem);
|
||||
|
||||
// Tamper RPS.Core.dll: flip first byte → md5 changes → manifest md5 check fails
|
||||
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
|
||||
byte[] bytes = File.ReadAllBytes(coreDll);
|
||||
bytes[0] ^= 0xFF;
|
||||
File.WriteAllBytes(coreDll, bytes);
|
||||
|
||||
// Large matchTimeoutTicks so 2 humans match before AI fill
|
||||
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-bad]"),
|
||||
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
|
||||
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(RpsJoinFrame()) });
|
||||
|
||||
// One DrainAndTick: match forms → CreateRoomForSeats → GameModuleLoader.Load
|
||||
// (detects tampered dll via md5 mismatch) → throws → caught by CreateRoomForSeats
|
||||
// → sends Error (code=1) to both real players
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
// Primary invariant: no MatchFound (room was never created)
|
||||
Assert.False(HasMatchFound(c1), "c1 must NOT receive MatchFound for a tampered module");
|
||||
Assert.False(HasMatchFound(c2), "c2 must NOT receive MatchFound for a tampered module");
|
||||
|
||||
// Secondary invariant: at least one player receives an Error frame
|
||||
// (CreateRoomForSeats sends Error to all realPids on load failure)
|
||||
Assert.True(HasError(c1) || HasError(c2),
|
||||
"At least one player should receive an Error frame when module load fails");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using XWorld.Server.Host;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class ServerLoopTests
|
||||
{
|
||||
private static ServerLoop NewLoop(long reconnectWindow = 100)
|
||||
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
|
||||
|
||||
private static byte[] JoinFrame(string gameId, int version)
|
||||
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
|
||||
|
||||
private static Frame LobbyJoinFrame(string name)
|
||||
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyJoin,
|
||||
new LobbyJoinMsg { Name = name, Figure = 1 }.Encode());
|
||||
|
||||
private static Frame LobbyMoveFrame(float x, float z, bool moving)
|
||||
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyMove,
|
||||
new LobbyMoveMsg
|
||||
{
|
||||
X = x,
|
||||
Y = 0f,
|
||||
Z = z,
|
||||
DirX = 1f,
|
||||
DirZ = 0f,
|
||||
Moving = moving
|
||||
}.Encode());
|
||||
|
||||
private static List<Frame> Frames(FakeConnection c)
|
||||
{
|
||||
var list = new List<Frame>();
|
||||
foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heartbeat_IsEchoed()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
var c = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||||
var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
|
||||
loop.Submit(new FrameCommand { PlayerId = 1,
|
||||
Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });
|
||||
|
||||
loop.DrainAndTick(0f);
|
||||
|
||||
var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
|
||||
Assert.NotEqual(default, echo);
|
||||
Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LobbyMove_IsBroadcastToAllLobbyPlayers()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyJoinFrame("editor") });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = LobbyJoinFrame("android") });
|
||||
loop.DrainAndTick(0f);
|
||||
|
||||
c1.Sent.Clear();
|
||||
c2.Sent.Clear();
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyMoveFrame(3f, 4f, true) });
|
||||
loop.DrainAndTick(0f);
|
||||
|
||||
Frame moveToSelf = Frames(c1).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
|
||||
Frame moveToOther = Frames(c2).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
|
||||
Assert.NotEqual(default, moveToSelf);
|
||||
Assert.NotEqual(default, moveToOther);
|
||||
|
||||
LobbyMoveMsg decoded = LobbyMoveMsg.Decode(moveToOther.Payload);
|
||||
Assert.Equal(1, decoded.PlayerId);
|
||||
Assert.Equal(3f, decoded.X);
|
||||
Assert.Equal(4f, decoded.Z);
|
||||
Assert.True(decoded.Moving);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconnect_ReroutesRoomOutputToNewConnection()
|
||||
{
|
||||
var loop = NewLoop(reconnectWindow: 100);
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.DrainAndTick(1f); // 2 人成团:c1/c2 收到 MatchFound + 快照
|
||||
Assert.True(c1.Sent.Count >= 2);
|
||||
|
||||
// pid=1 断线后重连到新连接 c1b
|
||||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
|
||||
var c1b = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
|
||||
loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b
|
||||
|
||||
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredSession_IsSwept()
|
||||
{
|
||||
var loop = NewLoop(reconnectWindow: 2);
|
||||
var c = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||||
loop.DrainAndTick(1f); // _tick: 0→1
|
||||
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
|
||||
loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
|
||||
loop.DrainAndTick(1f); // _tick→3:3-1=2,不 >2,保留
|
||||
loop.DrainAndTick(1f); // _tick→4:4-1=3 >2 → 过期清除
|
||||
Assert.False(loop.HasSession(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(2);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
|
||||
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.DrainAndTick(1f); // 2 人入房
|
||||
|
||||
// 已在房间内再次 join → 回 Error
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
loop.DrainAndTick(1f);
|
||||
|
||||
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameFrame_WithNoRoom_IsIgnoredSafely()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
var c = new FakeConnection(1);
|
||||
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
|
||||
var w = new PacketWriter(); w.WriteVarInt(3);
|
||||
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });
|
||||
|
||||
var ex = Record.Exception(() => loop.DrainAndTick(1f));
|
||||
Assert.Null(ex); // 无房间的游戏帧被安全忽略,不抛
|
||||
Assert.Empty(c.Sent); // 无任何输出
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchRequest_WithNoSession_IsIgnoredSafely()
|
||||
{
|
||||
var loop = NewLoop();
|
||||
// 未发 ConnectCommand,直接来一个 join 帧
|
||||
loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
|
||||
var ex = Record.Exception(() => loop.DrainAndTick(1f));
|
||||
Assert.Null(ex);
|
||||
Assert.False(loop.HasSession(99));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Xunit;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class SessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Connect_NewPid_CreatesConnectedSession()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c = new FakeConnection(1);
|
||||
sm.OnConnect(1, c);
|
||||
Assert.True(sm.IsConnected(1));
|
||||
Assert.Same(c, sm.GetConnection(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconnect_SamePid_RebindsToNewConnection()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(1);
|
||||
sm.OnConnect(1, c1);
|
||||
sm.OnDisconnect(1, c1, currentTick: 5);
|
||||
Assert.False(sm.IsConnected(1));
|
||||
Assert.Null(sm.GetConnection(1));
|
||||
|
||||
sm.OnConnect(1, c2); // 重连
|
||||
Assert.True(sm.IsConnected(1));
|
||||
Assert.Same(c2, sm.GetConnection(1)); // 路由到新连接
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleDisconnect_AfterReconnect_DoesNotClobber()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var c1 = new FakeConnection(1);
|
||||
var c2 = new FakeConnection(1);
|
||||
sm.OnConnect(1, c1);
|
||||
sm.OnConnect(1, c2); // 已重连到 c2
|
||||
sm.OnDisconnect(1, c1, currentTick: 9); // 旧连接 c1 的迟到断线不应清掉 c2
|
||||
Assert.True(sm.IsConnected(1));
|
||||
Assert.Same(c2, sm.GetConnection(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoomBinding_RoundTrips()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
sm.OnConnect(1, new FakeConnection(1));
|
||||
sm.Get(1).RoomId = "r1";
|
||||
Assert.Equal("r1", sm.Get(1).RoomId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SweepExpired_RemovesSessionsPastWindow()
|
||||
{
|
||||
var sm = new SessionManager();
|
||||
var conn = new FakeConnection(1);
|
||||
sm.OnConnect(1, conn);
|
||||
sm.OnDisconnect(1, conn, currentTick: 0);
|
||||
|
||||
var none = sm.SweepExpired(currentTick: 3, windowTicks: 5);
|
||||
Assert.Empty(none);
|
||||
Assert.NotNull(sm.Get(1));
|
||||
|
||||
var removed = sm.SweepExpired(currentTick: 6, windowTicks: 5); // 6-0 > 5
|
||||
Assert.Single(removed);
|
||||
Assert.Null(sm.Get(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using XWorld.Server.Gateway;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
public class StaticFileServerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ServesExistingFile_200_WithExactBytes()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "xw-cdn-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "minigame", "rps", "2"));
|
||||
byte[] payload = new byte[] { 1, 2, 3, 4, 250, 99, 0, 7 };
|
||||
File.WriteAllBytes(Path.Combine(root, "minigame", "rps", "2", "files.txt"), payload);
|
||||
|
||||
var cdn = new StaticFileServer(root, port: 0);
|
||||
await cdn.StartAsync();
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
HttpResponseMessage res = await http.GetAsync($"{cdn.HttpBaseUrl}/minigame/rps/2/files.txt");
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
byte[] got = await res.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(payload, got);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cdn.StopAsync();
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFile_404()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "xw-cdn-" + Guid.NewGuid().ToString("N"));
|
||||
var cdn = new StaticFileServer(root, port: 0);
|
||||
await cdn.StartAsync();
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
HttpResponseMessage res = await http.GetAsync($"{cdn.HttpBaseUrl}/minigame/nope/1/game.json");
|
||||
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cdn.StopAsync();
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LanIp_ReturnsNonLoopbackIPv4_OrFallback()
|
||||
{
|
||||
string ip = LanIp.Resolve();
|
||||
Assert.False(string.IsNullOrWhiteSpace(ip));
|
||||
Assert.True(System.Net.IPAddress.TryParse(ip, out var parsed));
|
||||
Assert.Equal(System.Net.Sockets.AddressFamily.InterNetwork, parsed.AddressFamily);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
internal sealed class FakeConnection : IClientConnection
|
||||
{
|
||||
public int PlayerId { get; }
|
||||
public List<byte[]> Sent { get; } = new List<byte[]>();
|
||||
public FakeConnection(int playerId) { PlayerId = playerId; }
|
||||
public void Send(byte[] frame) => Sent.Add(frame);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
internal static class TestGames
|
||||
{
|
||||
// 前置条件:先运行 build-test-games.sh 生成 Server.Host.Tests/TestGames(本工程复用之)
|
||||
public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XWorld.Server.Gateway.Tests
|
||||
{
|
||||
// 集成测试用 WS 客户端:连接、发帧、带超时收一帧并解码
|
||||
internal sealed class WsTestClient : IDisposable
|
||||
{
|
||||
private readonly ClientWebSocket _ws = new ClientWebSocket();
|
||||
|
||||
public async Task ConnectAsync(string wsBaseUrl, int pid)
|
||||
{
|
||||
await _ws.ConnectAsync(new Uri($"{wsBaseUrl}/ws?pid={pid}"), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task SendFrameAsync(Frame frame)
|
||||
{
|
||||
byte[] bytes = FrameCodec.Encode(frame);
|
||||
await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None);
|
||||
}
|
||||
|
||||
// 在 timeout 内收一条 WS 消息并解码为 Frame;超时抛 TimeoutException
|
||||
public async Task<Frame> ReceiveFrameAsync(int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var buf = new byte[8192];
|
||||
using var ms = new MemoryStream();
|
||||
WebSocketReceiveResult res;
|
||||
do
|
||||
{
|
||||
res = await _ws.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token);
|
||||
if (res.MessageType == WebSocketMessageType.Close)
|
||||
throw new InvalidOperationException("server closed");
|
||||
ms.Write(buf, 0, res.Count);
|
||||
} while (!res.EndOfMessage);
|
||||
return FrameCodec.Decode(ms.ToArray());
|
||||
}
|
||||
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); }
|
||||
catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
public void Dispose() => _ws.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user