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()); // 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 Frames(FakeConnection c) { var list = new List(); 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"); } /// /// Two clients send MatchRequest(rps,1) → they end up in the same room → game settles → both get RoomEnd /// [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"); } /// /// Single player sends MatchRequest, timeout triggers AI fill → settles /// [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"); } /// /// DuplicateMatchRequest while in queue should be rejected with Error /// [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); } /// /// 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. /// [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)"); } } }