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,233 @@
using System.Collections.Generic;
using System.Text;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using RPS.Core;
using RPS.Server;
namespace XWorld.Server.Host.Tests
{
public class RpsServerRoomTests
{
// ── helpers ────────────────────────────────────────────────────────
private sealed class FakeOutput : IRoomOutput
{
public readonly List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) { }
}
// Decode the last broadcast snapshot
private static RpsState LastSnapshot(FakeOutput cap)
{
var last = cap.Broadcasts[cap.Broadcasts.Count - 1];
return new RpsLogic().Decode(last.Payload);
}
// Build a NetMessage carrying a Choice byte (opcode=1)
private static NetMessage ChoiceMsg(Choice c)
{
var w = new PacketWriter();
w.WriteByte((byte)c);
return new NetMessage(RpsServerRoom.ChoiceOpcode, w.ToArray());
}
// Build a RoomCtx backed by FakeOutput; captures endRoom callback.
private static (RoomCtx ctx, FakeOutput output, bool[] ended, RoomEndResult[] result)
BuildCtx()
{
var output = new FakeOutput();
var ended = new bool[1];
var result = new RoomEndResult[1];
var ctx = new RoomCtx(
new DeterministicRandom(42),
new ManualClock(),
new ConsoleLogger(),
new InMemoryStorage(),
output,
r => { ended[0] = true; result[0] = r; });
return (ctx, output, ended, result);
}
private static readonly IReadOnlyList<PlayerInfo> TwoHumans = new[]
{
new PlayerInfo { PlayerId = 10, Name = "A", IsAI = false },
new PlayerInfo { PlayerId = 20, Name = "B", IsAI = false },
};
private static readonly IReadOnlyList<PlayerInfo> HumanAndAi = new[]
{
new PlayerInfo { PlayerId = 10, Name = "H", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
};
// ── T2-1: 2 human seats play a round and score correctly ──────────
[Fact]
public void TwoHumans_PlayRound_ScoresUpdateByRules()
{
var (ctx, output, _, _) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
// Both humans choose: seat0=Rock, seat1=Scissors → seat0 should win
room.OnMessage(10, ChoiceMsg(Choice.Rock));
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
// Advance past choosing timeout (5s) → round resolves
room.OnTick(5.1f);
Assert.True(output.Broadcasts.Count > 0);
var snap = LastSnapshot(output);
Assert.Equal(RpsPhase.Revealing, snap.Phase);
Assert.Equal(1, snap.Scores[0]);
Assert.Equal(0, snap.Scores[1]);
}
// ── T2-2: 2 human seats, reaching 60s ends the room ──────────────
[Fact]
public void TwoHumans_Reach60s_EndsRoom()
{
var (ctx, output, ended, _) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
// Give seat0 Rock, seat1 Scissors so seat0 leads after round 1
room.OnMessage(10, ChoiceMsg(Choice.Rock));
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
// Advance past choosing → Revealing
room.OnTick(5.1f);
// Advance past reveal → next round choosing
room.OnTick(5.1f);
// One huge tick past the 60s total → game finishes
room.OnTick(60f);
Assert.True(ended[0], "EndRoom must be called when total elapsed >= 60s");
var snap = LastSnapshot(output);
Assert.Equal(RpsPhase.Finished, snap.Phase);
Assert.Equal(0, snap.Winner); // seat0 won the only scored round
}
// ── T2-3: 1 human + 1 AI: AI auto-chooses, game settles ─────────
[Fact]
public void HumanAndAi_AutoAdvances_AndSettles()
{
var (ctx, output, ended, _) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(HumanAndAi, ctx);
// Human (seat0) chooses; AI (seat1 IsAI=true) auto-picks on first OnTick
room.OnMessage(10, ChoiceMsg(Choice.Rock));
// Tick past choosing (AI will have auto-chosen by now) → Revealing
room.OnTick(5.1f);
var snap1 = LastSnapshot(output);
Assert.Equal(RpsPhase.Revealing, snap1.Phase);
// AI must have picked something (not None)
Assert.NotEqual(Choice.None, snap1.Choices[1]);
// Tick past reveal → next round Choosing
room.OnTick(5.1f);
// Now push total time over 60s → game ends
room.OnTick(60f);
Assert.True(ended[0], "EndRoom must be called with AI seat");
var snap = LastSnapshot(output);
Assert.Equal(RpsPhase.Finished, snap.Phase);
// Winner is 0, 1, or 2 (draw) — just must be valid
Assert.True(snap.Winner >= 0 && snap.Winner <= 2);
}
// ── T2-4: broadcasts start immediately on OnRoomStart ────────────
[Fact]
public void OnRoomStart_BroadcastsInitialSnapshot()
{
var (ctx, output, _, _) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
Assert.True(output.Broadcasts.Count >= 1);
var snap = LastSnapshot(output);
Assert.Equal(1, snap.Round);
Assert.Equal(RpsPhase.Choosing, snap.Phase);
}
// ── T2-5: second choose from same seat is ignored (lock) ─────────
[Fact]
public void SecondChoiceFromSameSeat_IsIgnored()
{
var (ctx, output, _, _) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
room.OnMessage(10, ChoiceMsg(Choice.Rock));
room.OnMessage(10, ChoiceMsg(Choice.Scissors)); // should be ignored
// Advance past choosing timeout → resolve; seat0 chose Rock
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
room.OnTick(5.1f);
var snap = LastSnapshot(output);
Assert.Equal(Choice.Rock, snap.Choices[0]); // locked to Rock
Assert.Equal(1, snap.Scores[0]); // Rock beats Scissors
}
// ── 结算结果:胜者 pid + 比分文本经 EndRoom(result) 上报 ─────────
[Fact]
public void Finish_ReportsWinnerPidAndScoreText()
{
var (ctx, _, ended, result) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
// 每回合双方都出招(Rock vs Scissors → seat0 全胜),避免超时随机补招引入不确定性。
// 每回合 10.2s,第 6 回合 reveal 后 TotalElapsed=61.2 ≥ 60 → Finished
for (int round = 0; round < 6; round++)
{
room.OnMessage(10, ChoiceMsg(Choice.Rock));
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
room.OnTick(5.1f); // 结算本回合 → Revealing
room.OnTick(5.1f); // → 下一回合 Choosing(末回合触发 Finished
}
Assert.True(ended[0]);
Assert.NotNull(result[0]);
Assert.Equal(10, result[0].WinnerPlayerId); // seat0 的 pid
Assert.Equal("最终比分 6 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
}
[Fact]
public void Finish_Draw_ReportsPidZeroAndDrawText()
{
var (ctx, _, ended, result) = BuildCtx();
var room = new RpsServerRoom();
room.OnRoomStart(TwoHumans, ctx);
// 双方每回合同出 Rock → 每回合平 → 总分 0:0 → Winner=2(平局)
for (int round = 0; round < 6; round++)
{
room.OnMessage(10, ChoiceMsg(Choice.Rock));
room.OnMessage(20, ChoiceMsg(Choice.Rock));
room.OnTick(5.1f);
room.OnTick(5.1f);
}
Assert.True(ended[0]);
Assert.NotNull(result[0]);
Assert.Equal(0, result[0].WinnerPlayerId); // 平局 → 0
Assert.Equal("平局 0 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
}
}
}