Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user