Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
+49
View File
@@ -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(); }
}
}
}
+34
View File
@@ -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(); }
}
}
}
+88
View File
@@ -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); // 新连接收到
}
}
}
+200
View File
@@ -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 &lt; 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");
}
}
}
+168
View File
@@ -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→33-1=2,不 >2,保留
loop.DrainAndTick(1f); // _tick→44-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);
}
}
+11
View File
@@ -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();
}
}