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
@@ -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)");
}
}
}