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