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()); } /// /// 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) /// [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(); } } /// /// Single client + small matchTimeoutTicks → AI fill → client receives MatchFound with AI player → RoomEnd /// [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(); } } } }