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,73 @@
using System;
using Xunit;
using XWorld.Framework;
namespace XWorld.Framework.Tests
{
public class DeterministicRandomTests
{
[Fact]
public void SameSeed_ProducesIdenticalSequence()
{
var a = new DeterministicRandom(12345UL);
var b = new DeterministicRandom(12345UL);
for (int i = 0; i < 1000; i++)
Assert.Equal(a.Next(100), b.Next(100));
}
[Fact]
public void DifferentSeed_DivergesQuickly()
{
var a = new DeterministicRandom(1UL);
var b = new DeterministicRandom(2UL);
bool anyDiff = false;
for (int i = 0; i < 20; i++)
if (a.Next(1_000_000) != b.Next(1_000_000)) { anyDiff = true; break; }
Assert.True(anyDiff);
}
[Fact]
public void Next_StaysInRange()
{
var rng = new DeterministicRandom(999UL);
for (int i = 0; i < 100_000; i++)
{
int v = rng.Next(7);
Assert.InRange(v, 0, 6);
}
}
[Fact]
public void Next_One_AlwaysZero()
{
var rng = new DeterministicRandom(42UL);
for (int i = 0; i < 100; i++)
Assert.Equal(0, rng.Next(1));
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Next_NonPositiveMax_Throws(int max)
{
var rng = new DeterministicRandom(42UL);
Assert.Throws<ArgumentOutOfRangeException>((Action)(() => rng.Next(max)));
}
[Fact]
public void State_SnapshotAndResume_ContinuesIdentically()
{
var rng = new DeterministicRandom(7UL);
for (int i = 0; i < 10; i++) rng.Next(1000);
ulong snapshot = rng.State;
var resumed = new DeterministicRandom(snapshot);
// 注意:State 是当前内部状态,用它重建后序列从"下一步"继续
int[] fromOriginal = new int[5];
int[] fromResumed = new int[5];
for (int i = 0; i < 5; i++) fromOriginal[i] = rng.Next(1000);
for (int i = 0; i < 5; i++) fromResumed[i] = resumed.Next(1000);
Assert.Equal(fromOriginal, fromResumed);
}
}
}
@@ -0,0 +1,63 @@
using System;
using Xunit;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class DevDiscoveryCodecTests
{
[Fact]
public void Query_RoundTrips()
{
var q = new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = 0xABCDEF, Token = "dev-token" };
byte[] bytes = DevDiscoveryCodec.EncodeQuery(q);
Assert.True(DevDiscoveryCodec.TryDecodeQuery(bytes, out var got));
Assert.Equal(q.ProtoVersion, got.ProtoVersion);
Assert.Equal(q.Nonce, got.Nonce);
Assert.Equal(q.Token, got.Token);
}
[Fact]
public void Reply_RoundTrips()
{
var reply = new DevDiscoveryReply
{
ProtoVersion = DevDiscovery.ProtoVersion,
Nonce = 42,
ServerName = "dev-pc",
GatewayUrl = "ws://192.168.1.23:5005/ws",
ResourceBaseUrl = "http://192.168.1.23:15081/minigame/",
Token = "dev-token",
};
byte[] bytes = DevDiscoveryCodec.EncodeReply(reply);
Assert.True(DevDiscoveryCodec.TryDecodeReply(bytes, out var got));
Assert.Equal(reply.GatewayUrl, got.GatewayUrl);
Assert.Equal(reply.ResourceBaseUrl, got.ResourceBaseUrl);
Assert.Equal(reply.ServerName, got.ServerName);
Assert.Equal(reply.Nonce, got.Nonce);
Assert.Equal(reply.Token, got.Token);
}
[Fact]
public void BadMagic_Rejected()
{
byte[] bytes = { 0x00, 0x01, 0x02, 0x03, DevDiscovery.TypeQuery, 1 };
Assert.False(DevDiscoveryCodec.TryDecodeQuery(bytes, out _));
}
[Fact]
public void Truncated_Rejected()
{
Assert.False(DevDiscoveryCodec.TryDecodeQuery(new byte[] { 0x58, 0x57 }, out _));
Assert.False(DevDiscoveryCodec.TryDecodeReply(Array.Empty<byte>(), out _));
}
[Fact]
public void WrongType_Rejected()
{
var q = new DevDiscoveryQuery { ProtoVersion = 1, Nonce = 1, Token = "t" };
byte[] queryBytes = DevDiscoveryCodec.EncodeQuery(q);
Assert.False(DevDiscoveryCodec.TryDecodeReply(queryBytes, out _));
}
}
}
@@ -0,0 +1,46 @@
using System;
using Xunit;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class FrameCodecTests
{
[Fact]
public void Frame_RoundTrips()
{
var original = new Frame(Channel.Game, 7, new byte[] { 10, 20, 30 });
byte[] wire = FrameCodec.Encode(original);
Frame decoded = FrameCodec.Decode(wire);
Assert.Equal(Channel.Game, decoded.Channel);
Assert.Equal((ushort)7, decoded.Opcode);
Assert.Equal(new byte[] { 10, 20, 30 }, decoded.Payload);
}
[Fact]
public void Frame_EmptyPayload_RoundTrips()
{
var original = new Frame(Channel.Framework, 1, null);
Frame decoded = FrameCodec.Decode(FrameCodec.Encode(original));
Assert.Equal(Channel.Framework, decoded.Channel);
Assert.Equal((ushort)1, decoded.Opcode);
Assert.Empty(decoded.Payload);
}
[Fact]
public void Encode_WireLayout_IsChannelOpcodeLenPayload()
{
var f = new Frame(Channel.Game, 0x0102, new byte[] { 0xAA });
byte[] wire = FrameCodec.Encode(f);
// channel=1, opcode 小端 = 02 01, len varuint = 1, payload = AA
Assert.Equal(new byte[] { 0x01, 0x02, 0x01, 0x01, 0xAA }, wire);
}
[Fact]
public void Decode_Truncated_Throws()
{
Assert.Throws<FormatException>(() => FrameCodec.Decode(new byte[] { 0x00 }));
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Framework.Shared\Framework.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,150 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class FrameworkMessagesTests
{
[Fact]
public void Heartbeat_RoundTrips()
{
var m = new HeartbeatMsg { ClientTimeMs = 1_725_000_000_123L };
var d = HeartbeatMsg.Decode(m.Encode());
Assert.Equal(m.ClientTimeMs, d.ClientTimeMs);
}
[Fact]
public void MatchRequest_RoundTrips()
{
var m = new MatchRequestMsg { GameId = "rock_paper_scissors", Version = 3 };
var d = MatchRequestMsg.Decode(m.Encode());
Assert.Equal(m.GameId, d.GameId);
Assert.Equal(m.Version, d.Version);
}
[Fact]
public void MatchFound_RoundTrips_WithPlayers()
{
var m = new MatchFoundMsg
{
RoomId = "room-42",
Version = 3,
SelfPlayerId = 1001,
Players =
{
new PlayerInfo { PlayerId = 1001, Name = "Alice", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
},
};
var d = MatchFoundMsg.Decode(m.Encode());
Assert.Equal("room-42", d.RoomId);
Assert.Equal(3, d.Version);
Assert.Equal(1001, d.SelfPlayerId);
Assert.Equal(2, d.Players.Count);
Assert.Equal(1001, d.Players[0].PlayerId);
Assert.Equal("Alice", d.Players[0].Name);
Assert.False(d.Players[0].IsAI);
Assert.Equal(-1, d.Players[1].PlayerId);
Assert.True(d.Players[1].IsAI);
}
[Fact]
public void GameInput_RoundTrips()
{
var m = new GameInputMsg { GameOpcode = 12, Payload = new byte[] { 9, 8, 7 } };
var d = GameInputMsg.Decode(m.Encode());
Assert.Equal((ushort)12, d.GameOpcode);
Assert.Equal(new byte[] { 9, 8, 7 }, d.Payload);
}
[Fact]
public void Snapshot_RoundTrips()
{
var m = new SnapshotMsg { Tick = 250, StateBlob = new byte[] { 0xDE, 0xAD } };
var d = SnapshotMsg.Decode(m.Encode());
Assert.Equal(250, d.Tick);
Assert.Equal(new byte[] { 0xDE, 0xAD }, d.StateBlob);
}
[Fact]
public void RoomEnd_RoundTrips()
{
var m = new RoomEndMsg { WinnerPlayerId = 1001, ResultBlob = new byte[] { 1 } };
var d = RoomEndMsg.Decode(m.Encode());
Assert.Equal(1001, d.WinnerPlayerId);
Assert.Equal(new byte[] { 1 }, d.ResultBlob);
}
[Fact]
public void Error_RoundTrips()
{
var m = new ErrorMsg { Code = 404, Message = "version mismatch" };
var d = ErrorMsg.Decode(m.Encode());
Assert.Equal(404, d.Code);
Assert.Equal("version mismatch", d.Message);
}
[Fact]
public void Opcodes_AreStableValues()
{
// 线网兼容性:opcode 数值固定,改动等于破坏协议
Assert.Equal((ushort)1, (ushort)FrameworkOpcode.Heartbeat);
Assert.Equal((ushort)2, (ushort)FrameworkOpcode.MatchRequest);
Assert.Equal((ushort)3, (ushort)FrameworkOpcode.MatchFound);
Assert.Equal((ushort)4, (ushort)FrameworkOpcode.GameInput);
Assert.Equal((ushort)5, (ushort)FrameworkOpcode.Snapshot);
Assert.Equal((ushort)6, (ushort)FrameworkOpcode.RoomEnd);
Assert.Equal((ushort)7, (ushort)FrameworkOpcode.Error);
}
[Fact]
public void Message_TravelsInsideFrame()
{
// 端到端:DTO -> payload -> Frame -> wire -> Frame -> DTO
var req = new MatchRequestMsg { GameId = "rps", Version = 1 };
var frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest, req.Encode());
byte[] wire = FrameCodec.Encode(frame);
Frame back = FrameCodec.Decode(wire);
Assert.Equal(Channel.Framework, back.Channel);
Assert.Equal((ushort)FrameworkOpcode.MatchRequest, back.Opcode);
var d = MatchRequestMsg.Decode(back.Payload);
Assert.Equal("rps", d.GameId);
Assert.Equal(1, d.Version);
}
[Fact]
public void GameInput_NullPayload_RoundTrips_AsEmpty()
{
var m = new GameInputMsg { GameOpcode = 5, Payload = null };
var d = GameInputMsg.Decode(m.Encode());
Assert.Equal((ushort)5, d.GameOpcode);
Assert.NotNull(d.Payload);
Assert.Empty(d.Payload);
}
[Fact]
public void Error_NullMessage_RoundTrips_AsEmpty()
{
var m = new ErrorMsg { Code = 7, Message = null };
var d = ErrorMsg.Decode(m.Encode());
Assert.Equal(7, d.Code);
Assert.Equal(string.Empty, d.Message);
}
[Fact]
public void MatchFound_EmptyPlayerList_RoundTrips()
{
var m = new MatchFoundMsg { RoomId = "r0", Version = 1, SelfPlayerId = 0 };
// Players 默认空列表,不添加任何玩家
var d = MatchFoundMsg.Decode(m.Encode());
Assert.Equal("r0", d.RoomId);
Assert.Equal(1, d.Version);
Assert.Equal(0, d.SelfPlayerId);
Assert.NotNull(d.Players);
Assert.Empty(d.Players);
}
}
}
@@ -0,0 +1,139 @@
using System;
using Xunit;
using XWorld.Framework.Protocol;
namespace XWorld.Framework.Tests
{
public class PacketCodecTests
{
[Theory]
[InlineData(0u)]
[InlineData(1u)]
[InlineData(127u)]
[InlineData(128u)]
[InlineData(16384u)]
[InlineData(uint.MaxValue)]
public void VarUInt_RoundTrips(uint value)
{
var w = new PacketWriter();
w.WriteVarUInt(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarUInt());
Assert.False(r.HasMore);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(-1)]
[InlineData(int.MaxValue)]
[InlineData(int.MinValue)]
public void VarInt_RoundTrips(int value)
{
var w = new PacketWriter();
w.WriteVarInt(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarInt());
}
[Theory]
[InlineData(0L)]
[InlineData(-1234567890123L)]
[InlineData(long.MaxValue)]
[InlineData(long.MinValue)]
public void VarLong_RoundTrips(long value)
{
var w = new PacketWriter();
w.WriteVarLong(value);
var r = new PacketReader(w.ToArray());
Assert.Equal(value, r.ReadVarLong());
}
[Fact]
public void MixedSequence_RoundTrips_InOrder()
{
var w = new PacketWriter();
w.WriteBool(true);
w.WriteByte(200);
w.WriteUInt16(50000);
w.WriteVarInt(-42);
w.WriteSingle(3.14159f);
w.WriteString("héllo 世界");
w.WriteBytes(new byte[] { 1, 2, 3, 0, 255 });
var r = new PacketReader(w.ToArray());
Assert.True(r.ReadBool());
Assert.Equal((byte)200, r.ReadByte());
Assert.Equal((ushort)50000, r.ReadUInt16());
Assert.Equal(-42, r.ReadVarInt());
Assert.Equal(3.14159f, r.ReadSingle(), 5);
Assert.Equal("héllo 世界", r.ReadString());
Assert.Equal(new byte[] { 1, 2, 3, 0, 255 }, r.ReadBytes());
Assert.False(r.HasMore);
}
[Fact]
public void EmptyAndNull_String_Bytes_RoundTripAsEmpty()
{
var w = new PacketWriter();
w.WriteString(null);
w.WriteString("");
w.WriteBytes(null);
w.WriteBytes(Array.Empty<byte>());
var r = new PacketReader(w.ToArray());
Assert.Equal("", r.ReadString());
Assert.Equal("", r.ReadString());
Assert.Empty(r.ReadBytes());
Assert.Empty(r.ReadBytes());
}
[Fact]
public void ReadPastEnd_Throws()
{
var r = new PacketReader(Array.Empty<byte>());
Assert.Throws<FormatException>(() => r.ReadByte());
}
[Fact]
public void ReadBytes_WithBogusLength_Throws()
{
// 长度前缀声称 10 字节,实际无数据
var w = new PacketWriter();
w.WriteVarUInt(10);
var r = new PacketReader(w.ToArray());
Assert.Throws<FormatException>(() => r.ReadBytes());
}
[Fact]
public void ReadVarUInt_SixthByte_Throws()
{
// 5 个延续字节后还有第 6 字节 → 过长,必须抛异常而非静默损坏
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x8F, 0x00 });
Assert.Throws<FormatException>(() => r.ReadVarUInt());
}
[Fact]
public void ReadVarUInt_LastByteOverflow_Throws()
{
// 第 5 字节 0x10 表示值超过 uint.MaxValue
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0x10 });
Assert.Throws<FormatException>(() => r.ReadVarUInt());
}
[Fact]
public void ReadVarLong_EleventhByte_Throws()
{
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x00 });
Assert.Throws<FormatException>(() => r.ReadVarLong());
}
[Fact]
public void ReadVarLong_LastByteOverflow_Throws()
{
// 第 10 字节 0x02 表示值超过 ulong 的 64 位
var r = new PacketReader(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02 });
Assert.Throws<FormatException>(() => r.ReadVarLong());
}
}
}