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
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.Text;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class AccountStoreTests
{
// 用共享缓存的内存库(连接保持打开期间存活)
static AccountStore NewStore() => new AccountStore("Data Source=acct_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
[Fact]
public void CreateAccount_returns_positive_id()
{
using var s = NewStore();
int id = s.CreateAccount("alice", "pw123456");
Assert.True(id > 0);
}
[Fact]
public void CreateAccount_duplicate_username_rejected_case_insensitive()
{
using var s = NewStore();
s.CreateAccount("Bob", "pw123456");
Assert.Throws<DuplicateUsernameException>(() => s.CreateAccount("bob", "other123"));
}
[Fact]
public void Verify_correct_password_returns_same_id()
{
using var s = NewStore();
int id = s.CreateAccount("carol", "secret-pw");
Assert.Equal(id, s.VerifyCredentials("carol", "secret-pw"));
}
[Fact]
public void Verify_wrong_password_returns_null()
{
using var s = NewStore();
s.CreateAccount("dave", "secret-pw");
Assert.Null(s.VerifyCredentials("dave", "WRONG"));
}
[Fact]
public void Verify_unknown_user_returns_null()
{
using var s = NewStore();
Assert.Null(s.VerifyCredentials("nobody", "x"));
}
[Fact]
public void Password_not_stored_in_plaintext()
{
using var s = NewStore();
s.CreateAccount("erin", "plaintext-marker");
Assert.False(s.DebugRawContains(Encoding.UTF8.GetBytes("plaintext-marker")));
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</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>
<!-- AuthEndpointsTests 用 WebApplication/HttpClient 起真 Kestrel -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Auth\Auth.csproj" />
</ItemGroup>
</Project>
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class AuthEndpointsTests : IAsyncLifetime
{
WebApplication _app;
HttpClient _http;
AccountStore _store;
public async Task InitializeAsync()
{
_store = new AccountStore("Data Source=ep_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
var tokens = new TokenService(new byte[32]);
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o => o.Listen(System.Net.IPAddress.Loopback, 0));
_app = builder.Build();
AuthEndpoints.Map(_app, _store, tokens, TimeSpan.FromHours(1));
await _app.StartAsync();
string baseUrl = _app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses.First();
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
}
public async Task DisposeAsync() { _http?.Dispose(); _store?.Dispose(); await _app.DisposeAsync(); }
[Fact]
public async Task Register_then_login_yields_token_and_pid()
{
var reg = await _http.PostAsJsonAsync("/register", new { username = "neo", password = "redpill1" });
Assert.Equal(HttpStatusCode.OK, reg.StatusCode);
var regBody = await reg.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
Assert.True(regBody.pid > 0);
Assert.False(string.IsNullOrEmpty(regBody.token));
var login = await _http.PostAsJsonAsync("/login", new { username = "neo", password = "redpill1" });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var loginBody = await login.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
Assert.Equal(regBody.pid, loginBody.pid);
}
[Fact]
public async Task Register_duplicate_returns_409()
{
await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
var again = await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task Login_wrong_password_returns_401()
{
await _http.PostAsJsonAsync("/register", new { username = "trinity", password = "pw123456" });
var bad = await _http.PostAsJsonAsync("/login", new { username = "trinity", password = "WRONG" });
Assert.Equal(HttpStatusCode.Unauthorized, bad.StatusCode);
}
[Fact]
public async Task Register_empty_fields_returns_400()
{
var r = await _http.PostAsJsonAsync("/register", new { username = "", password = "" });
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using System;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class TokenServiceTests
{
static byte[] Key() => new byte[32]; // 全零密钥,测试用固定值
[Fact]
public void Issue_then_verify_roundtrip_returns_pid()
{
var t = new TokenService(Key());
string tok = t.Issue(4242, TimeSpan.FromHours(1));
Assert.True(t.Verify(tok, out int pid));
Assert.Equal(4242, pid);
}
[Fact]
public void Tampered_token_rejected()
{
var t = new TokenService(Key());
string tok = t.Issue(7, TimeSpan.FromHours(1));
string bad = tok.Substring(0, tok.Length - 1) + (tok[tok.Length - 1] == 'A' ? 'B' : 'A');
Assert.False(t.Verify(bad, out _));
}
[Fact]
public void Expired_token_rejected()
{
var t = new TokenService(Key());
string tok = t.Issue(7, TimeSpan.FromSeconds(-1)); // 已过期
Assert.False(t.Verify(tok, out _));
}
[Fact]
public void Wrong_key_rejected()
{
var signer = new TokenService(Key());
string tok = signer.Issue(7, TimeSpan.FromHours(1));
var other = new TokenService(new byte[32] { 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 });
Assert.False(other.Verify(tok, out _));
}
[Fact]
public void Garbage_token_rejected()
{
var t = new TokenService(Key());
Assert.False(t.Verify("not-a-token", out _));
Assert.False(t.Verify("", out _));
Assert.False(t.Verify(null, out _));
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;
namespace XWorld.Server.Auth
{
// SQLite 账号库。保持单条长连接(内存库测试 + 文件库生产均可),低频鉴权用 lock 串行化。
public sealed class AccountStore : IDisposable
{
const int SaltLen = 16;
const int HashLen = 32;
const int Iterations = 100_000;
readonly SqliteConnection _conn;
readonly object _lock = new object();
// dbPath: 文件路径(生产) 或完整连接串(以 "Data Source=" 开头,测试用内存库)
public AccountStore(string dbPath)
{
string cs = dbPath.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)
? dbPath
: "Data Source=" + dbPath;
_conn = new SqliteConnection(cs);
_conn.Open();
Exec("PRAGMA journal_mode=WAL;");
Exec(@"CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
pwd_hash BLOB NOT NULL,
salt BLOB NOT NULL,
iter INTEGER NOT NULL,
created_at INTEGER NOT NULL);");
}
public int CreateAccount(string username, string password)
{
if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username");
if (string.IsNullOrEmpty(password)) throw new ArgumentException("password");
byte[] salt = RandomNumberGenerator.GetBytes(SaltLen);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, HashLen);
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "INSERT INTO accounts(username,pwd_hash,salt,iter,created_at) VALUES($u,$h,$s,$i,$t);";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$h", hash);
cmd.Parameters.AddWithValue("$s", salt);
cmd.Parameters.AddWithValue("$i", Iterations);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
try { cmd.ExecuteNonQuery(); }
catch (SqliteException e) when (e.SqliteErrorCode == 19) // SQLITE_CONSTRAINT (UNIQUE)
{ throw new DuplicateUsernameException(username); }
using var idCmd = _conn.CreateCommand();
idCmd.CommandText = "SELECT last_insert_rowid();";
return (int)(long)idCmd.ExecuteScalar();
}
}
public int? VerifyCredentials(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT id,pwd_hash,salt,iter FROM accounts WHERE username=$u COLLATE NOCASE;";
cmd.Parameters.AddWithValue("$u", username);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
int id = (int)r.GetInt64(0);
byte[] stored = (byte[])r["pwd_hash"];
byte[] salt = (byte[])r["salt"];
int iter = (int)r.GetInt64(3);
byte[] cand = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, iter, HashAlgorithmName.SHA256, stored.Length);
return CryptographicOperations.FixedTimeEquals(cand, stored) ? id : (int?)null;
}
}
// 测试辅助:原始库字节是否包含某序列(验证明文不入库)
public bool DebugRawContains(byte[] needle)
{
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT pwd_hash,salt FROM accounts;";
using var r = cmd.ExecuteReader();
while (r.Read())
{
if (Contains((byte[])r["pwd_hash"], needle) || Contains((byte[])r["salt"], needle))
return true;
}
return false;
}
}
static bool Contains(byte[] hay, byte[] needle)
{
if (needle.Length == 0 || hay.Length < needle.Length) return false;
for (int i = 0; i <= hay.Length - needle.Length; i++)
{
bool ok = true;
for (int j = 0; j < needle.Length; j++) if (hay[i + j] != needle[j]) { ok = false; break; }
if (ok) return true;
}
return false;
}
void Exec(string sql)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
public void Dispose() => _conn?.Dispose();
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Auth</AssemblyName>
<RootNamespace>XWorld.Server.Auth</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- AuthEndpoints 用到 ASP.NET 类型(IEndpointRouteBuilder/Results/MapPost -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- NU1903: 传递依赖 SQLitePCLRaw.lib.e_sqlite3 有已知 SQLite 漏洞(GHSA-2m69-gcr7-jv3q)
2.1.10/2.1.11 均被标记;待 SQLitePCLRaw 发布修复版后 bump 此包即可消除。功能不受影响。 -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
</ItemGroup>
</Project>
+36
View File
@@ -0,0 +1,36 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace XWorld.Server.Auth
{
public static class AuthEndpoints
{
public sealed class AuthRequest { public string username { get; set; } public string password { get; set; } }
public sealed class AuthResponse { public string token { get; set; } public int pid { get; set; } }
public static void Map(IEndpointRouteBuilder app, AccountStore store, TokenService tokens, TimeSpan tokenTtl)
{
app.MapPost("/register", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int pid;
try { pid = store.CreateAccount(req.username, req.password); }
catch (DuplicateUsernameException) { return Results.Conflict(new { error = "username_taken" }); }
catch (ArgumentException) { return Results.BadRequest(new { error = "invalid_input" }); }
return Results.Ok(new AuthResponse { token = tokens.Issue(pid, tokenTtl), pid = pid });
});
app.MapPost("/login", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int? pid = store.VerifyCredentials(req.username, req.password);
if (pid == null) return Results.Json(new { error = "invalid_credentials" }, statusCode: 401);
return Results.Ok(new AuthResponse { token = tokens.Issue(pid.Value, tokenTtl), pid = pid.Value });
});
}
}
}
+10
View File
@@ -0,0 +1,10 @@
using System;
namespace XWorld.Server.Auth
{
public sealed class DuplicateUsernameException : Exception
{
public DuplicateUsernameException(string username)
: base("username already taken: " + username) { }
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace XWorld.Server.Auth
{
// 无状态 HMAC-SHA256 token。格式: "v1.{pid}.{expUnix}.{base64url(hmac(payload))}"
// 其中 payload = "v1.{pid}.{expUnix}"。
public sealed class TokenService
{
readonly byte[] _key;
public TokenService(byte[] key)
{
if (key == null || key.Length < 16) throw new ArgumentException("key too short");
_key = key;
}
public string Issue(int pid, TimeSpan ttl)
{
long exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
string payload = "v1." + pid + "." + exp;
return payload + "." + B64Url(Sign(payload));
}
public bool Verify(string token, out int pid)
{
pid = 0;
if (string.IsNullOrEmpty(token)) return false;
int lastDot = token.LastIndexOf('.');
if (lastDot <= 0) return false;
string payload = token.Substring(0, lastDot);
string sig = token.Substring(lastDot + 1);
byte[] expected = Sign(payload);
byte[] got;
try { got = FromB64Url(sig); } catch { return false; }
if (!CryptographicOperations.FixedTimeEquals(expected, got)) return false;
string[] parts = payload.Split('.'); // ["v1", pid, exp]
if (parts.Length != 3 || parts[0] != "v1") return false;
if (!int.TryParse(parts[1], out pid)) return false;
if (!long.TryParse(parts[2], out long exp)) return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) return false;
return true;
}
byte[] Sign(string payload)
{
using var h = new HMACSHA256(_key);
return h.ComputeHash(Encoding.UTF8.GetBytes(payload));
}
static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] FromB64Url(string s)
{
string b = s.Replace('-', '+').Replace('_', '/');
switch (b.Length % 4) { case 2: b += "=="; break; case 3: b += "="; break; }
return Convert.FromBase64String(b);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Cdn.Runner</AssemblyName>
<RootNamespace>XWorld.Server.Cdn.Runner</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Gateway\Gateway.csproj" />
</ItemGroup>
</Project>
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Server.Gateway;
namespace XWorld.Server.Cdn.Runner
{
// 独立内网 CDN(HTTP 静态文件服务)。仿 Gateway.Runner 形态。
// 用法: dotnet run --project Server/Cdn.Runner -- --root <发布产物>/client --port 15081 --lan
public static class Program
{
public static async Task Main(string[] args)
{
string root = GetArg(args, "--root");
if (string.IsNullOrWhiteSpace(root))
{
Console.Error.WriteLine("缺少必填参数 --root <目录>(指向发布产物 client/ 目录,含 minigame/<id>/<ver>/...");
Environment.ExitCode = 2;
return;
}
root = Path.GetFullPath(root);
int port = int.TryParse(GetArg(args, "--port"), out int p) ? p : 15081;
bool lan = Array.Exists(args, a => string.Equals(a, "--lan", StringComparison.OrdinalIgnoreCase));
var cdn = new StaticFileServer(root, port, bindAllInterfaces: lan);
await cdn.StartAsync();
string advertiseHost = lan ? LanIp.Resolve() : "127.0.0.1";
Console.WriteLine("XWorld CDN static file server started.");
Console.WriteLine(" root: " + root);
Console.WriteLine(" listen: " + cdn.HttpBaseUrl + (lan ? " (all interfaces)" : " (loopback)"));
Console.WriteLine(" advertise cdn root: http://" + advertiseHost + ":" + port + "/ (XWorld/<platform>/ , minigame/<id>/<ver>/)");
Console.WriteLine("Press Ctrl+C to stop.");
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
stop.Cancel();
};
try { await Task.Delay(Timeout.InfiniteTimeSpan, stop.Token); }
catch (OperationCanceledException) { }
await cdn.StopAsync();
}
private static string GetArg(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
return null;
}
}
}
@@ -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());
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Framework.Shared</AssemblyName>
<RootNamespace>XWorld.Framework</RootNamespace>
<!-- 不编译本目录下的 .cs,规范源码全部来自 Client/Assets/Framework/Shared -->
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Gateway.Runner</AssemblyName>
<RootNamespace>XWorld.Server.Gateway.Runner</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Gateway\Gateway.csproj" />
</ItemGroup>
</Project>
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Runner
{
public static class Program
{
public static async Task Main(string[] args)
{
string gamesRoot = GetArg(args, "--gamesRoot") ??
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Server.Host.Tests", "TestGames"));
int port = int.TryParse(GetArg(args, "--port"), out int p) ? p : 5005;
int tickMs = int.TryParse(GetArg(args, "--tickMs"), out int t) ? t : 100;
long matchTimeoutTicks = long.TryParse(GetArg(args, "--matchTimeoutTicks"), out long mt) ? mt : 150;
string devToken = GetArg(args, "--devToken");
string advertiseWs = GetArg(args, "--advertiseWs");
string advertiseCdn = GetArg(args, "--advertiseCdn");
bool lan = Array.Exists(args, a => string.Equals(a, "--lan", StringComparison.OrdinalIgnoreCase));
int cdnPort = int.TryParse(GetArg(args, "--cdnPort"), out int cp) ? cp : 15081;
// --lan 下若未显式给 CDN 地址,用本机 LAN IP + 约定端口兜底广而告之 CDN 根
// (客户端从根派生两套:minigame/<id>/<ver>/ 与 XWorld/<platform>/advertiseWs 缺省由 GameServerHost 内部用 LanIp 兜底)
if (lan && string.IsNullOrEmpty(advertiseCdn))
advertiseCdn = $"http://{XWorld.Server.Gateway.LanIp.Resolve()}:{cdnPort}/";
// 鉴权 opt-in:给了 --authDbPath 才启用账号鉴权(/register、/login、/ws 验 token)。
// 密钥优先级:--authSecret(base64) > DB 同目录 auth.key 文件(不存在则生成并持久化)。
string authDbPath = GetArg(args, "--authDbPath");
string authSecretB64 = GetArg(args, "--authSecret");
byte[] authSecret = null;
if (!string.IsNullOrEmpty(authDbPath))
{
if (!string.IsNullOrEmpty(authSecretB64))
authSecret = Convert.FromBase64String(authSecretB64);
else
{
string keyPath = Path.Combine(
Path.GetDirectoryName(Path.GetFullPath(authDbPath)) ?? ".", "auth.key");
if (File.Exists(keyPath))
authSecret = Convert.FromBase64String(File.ReadAllText(keyPath).Trim());
else
{
authSecret = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
File.WriteAllText(keyPath, Convert.ToBase64String(authSecret));
}
}
}
var host = new GameServerHost();
await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
bindAllInterfaces: lan,
devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn,
authSecret: authSecret, authDbPath: authDbPath);
Console.WriteLine("XWorld Gateway smoke server started.");
Console.WriteLine(" gamesRoot: " + gamesRoot);
Console.WriteLine(" tickMs: " + tickMs + ", matchTimeoutTicks: " + matchTimeoutTicks);
Console.WriteLine(" ws: " + host.WsBaseUrl + "/ws?pid=1");
if (!string.IsNullOrEmpty(devToken))
Console.WriteLine(" dev-discovery: ON (UDP " + host.DevDiscoveryPort + "), advertise ws=" +
(string.IsNullOrEmpty(advertiseWs) ? host.WsBaseUrl + "/ws" : advertiseWs) + " cdn=" + advertiseCdn);
Console.WriteLine(" auth: " + (authSecret != null ? ("ON (db=" + authDbPath + ", /register /login /ws?token=)") : "OFF (dev ?pid=)"));
Console.WriteLine("Press Ctrl+C to stop.");
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
stop.Cancel();
};
try { await Task.Delay(Timeout.InfiniteTimeSpan, stop.Token); }
catch (OperationCanceledException) { }
await host.StopAsync();
}
private static string GetArg(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
return null;
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using XWorld.Server.Auth;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class AuthWsTests
{
static string GamesRoot() => System.IO.Path.Combine(AppContext.BaseDirectory, "TestGames");
[Fact]
public async Task Ws_with_valid_token_connects()
{
var key = new byte[32];
var host = new GameServerHost();
await host.StartAsync(GamesRoot(), 100, authSecret: key,
authDbPath: "Data Source=wsok_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
try
{
string token = new TokenService(key).Issue(1234, TimeSpan.FromHours(1));
using var ws = new ClientWebSocket();
var uri = new Uri(host.WsBaseUrl + "/ws?token=" + token);
await ws.ConnectAsync(uri, CancellationToken.None); // 不抛即握手成功
Assert.Equal(WebSocketState.Open, ws.State);
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Ws_with_invalid_token_rejected()
{
var key = new byte[32];
var host = new GameServerHost();
await host.StartAsync(GamesRoot(), 100, authSecret: key,
authDbPath: "Data Source=wsbad_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
try
{
using var ws = new ClientWebSocket();
var uri = new Uri(host.WsBaseUrl + "/ws?token=garbage");
await Assert.ThrowsAnyAsync<WebSocketException>(() => ws.ConnectAsync(uri, CancellationToken.None));
}
finally { await host.StopAsync(); }
}
}
}
@@ -0,0 +1,82 @@
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class DevDiscoveryResponderTests
{
private static async Task<DevDiscoveryReply?> QueryAsync(int port, string token, uint nonce, int timeoutMs)
{
using var client = new UdpClient(AddressFamily.InterNetwork);
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
{
ProtoVersion = DevDiscovery.ProtoVersion,
Nonce = nonce,
Token = token,
});
await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, port));
var recv = client.ReceiveAsync();
var done = await Task.WhenAny(recv, Task.Delay(timeoutMs));
if (done != recv) return null;
UdpReceiveResult result = await recv;
if (!DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply)) return null;
return reply;
}
[Fact]
public async Task ValidQuery_ReceivesReply_WithAdvertisedAddresses()
{
var responder = new DevDiscoveryResponder(
"dev-pc", "ws://192.168.1.23:5005/ws", "http://192.168.1.23:15081/minigame/",
token: "secret", listenPort: 0);
responder.Start();
try
{
var reply = await QueryAsync(responder.Port, "secret", nonce: 7, timeoutMs: 2000);
Assert.NotNull(reply);
Assert.Equal("ws://192.168.1.23:5005/ws", reply.Value.GatewayUrl);
Assert.Equal("http://192.168.1.23:15081/minigame/", reply.Value.ResourceBaseUrl);
Assert.Equal("dev-pc", reply.Value.ServerName);
Assert.Equal(7u, reply.Value.Nonce);
}
finally { await responder.StopAsync(); }
}
[Fact]
public async Task WrongToken_NoReply()
{
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
responder.Start();
try
{
var reply = await QueryAsync(responder.Port, "WRONG", nonce: 1, timeoutMs: 800);
Assert.Null(reply);
}
finally { await responder.StopAsync(); }
}
[Fact]
public async Task GarbagePacket_Ignored_LoopStillAnswers()
{
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
responder.Start();
try
{
using (var raw = new UdpClient(AddressFamily.InterNetwork))
{
byte[] junk = { 1, 2, 3 };
await raw.SendAsync(junk, junk.Length, new IPEndPoint(IPAddress.Loopback, responder.Port));
}
var reply = await QueryAsync(responder.Port, "secret", nonce: 9, timeoutMs: 2000);
Assert.NotNull(reply);
Assert.Equal(9u, reply.Value.Nonce);
}
finally { await responder.StopAsync(); }
}
}
}
@@ -0,0 +1,53 @@
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class GameServerHostDiscoveryTests
{
[Fact]
public async Task HostWithoutToken_NoResponder()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 50);
try { Assert.Equal(0, host.DevDiscoveryPort); }
finally { await host.StopAsync(); }
}
[Fact]
public async Task HostWithToken_AnswersDiscovery_AdvertisesWsAndCdn()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 50,
devDiscoveryToken: "secret",
advertiseResourceBaseUrl: "http://192.168.1.50:15081/minigame/",
devDiscoveryPort: 0);
try
{
Assert.NotEqual(0, host.DevDiscoveryPort);
using var client = new UdpClient(AddressFamily.InterNetwork);
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
{
ProtoVersion = DevDiscovery.ProtoVersion,
Nonce = 3,
Token = "secret",
});
await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, host.DevDiscoveryPort));
var recv = client.ReceiveAsync();
var done = await Task.WhenAny(recv, Task.Delay(2000));
Assert.Same(recv, done);
UdpReceiveResult result = await recv;
Assert.True(DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply));
Assert.Equal(host.WsBaseUrl + "/ws", reply.GatewayUrl);
Assert.Equal("http://192.168.1.50:15081/minigame/", reply.ResourceBaseUrl);
}
finally { await host.StopAsync(); }
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</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="..\Gateway\Gateway.csproj" />
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
</ItemGroup>
<ItemGroup>
<!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
<Content Include="..\Server.Host.Tests\TestGames\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,83 @@
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class GatewayEndToEndTests
{
private static Frame JoinRps()
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
[Fact]
public async Task Heartbeat_IsEchoedBack()
{
var host = new GameServerHost();
await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
try
{
using var client = new WsTestClient();
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));
Frame? echo = null;
for (int i = 0; i < 10; i++)
{
var f = await client.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
}
Assert.True(echo.HasValue, "未收到心跳回显");
Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
await client.CloseAsync();
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Reconnect_ContinuesReceivingFromSameRoom()
{
var host = new GameServerHost();
// matchTimeoutTicks=3 → 单人后 AI 兜底成团;logicalDt=0.2 让 60s 对局慢跑;大重连窗口
await host.StartAsync(TestGames.Root, tickIntervalMs: 200,
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 0.2f);
try
{
var client1 = new WsTestClient();
await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
await client1.SendFrameAsync(JoinRps());
// 收到 MatchFound 后的第一个游戏快照
bool gotSnapshot = false;
for (int i = 0; i < 15 && !gotSnapshot; i++)
{
var f = await client1.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotSnapshot = true;
}
Assert.True(gotSnapshot, "重连前应先收到一个快照");
// 断开
await client1.CloseAsync();
client1.Dispose();
// 用同 pid 重连
using var client2 = new WsTestClient();
await client2.ConnectAsync(host.WsBaseUrl, pid: 42);
// 房间仍在跑,下一个房间输出应发到 client2
bool gotAfterReconnect = false;
for (int i = 0; i < 15 && !gotAfterReconnect; i++)
{
var f = await client2.ReceiveFrameAsync(5000);
if (f.Channel == Channel.Game) gotAfterReconnect = true;
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) gotAfterReconnect = true;
}
Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
await client2.CloseAsync();
}
finally { await host.StopAsync(); }
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using Xunit;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class MatchmakerTests
{
[Fact]
public void TwoEnqueues_SameBucket_PlayerCount2_Poll_ReturnsTwoHumanMatch()
{
var mm = new Matchmaker(timeoutTicks: 100);
mm.Enqueue(pid: 1, gameId: "rps", version: 1, playerCount: 2, tick: 0);
mm.Enqueue(pid: 2, gameId: "rps", version: 1, playerCount: 2, tick: 0);
var matches = mm.Poll(currentTick: 0);
Assert.Single(matches);
var m = matches[0];
Assert.Equal("rps", m.GameId);
Assert.Equal(1, m.Version);
Assert.Equal(2, m.Seats.Count);
Assert.False(m.Seats[0].IsAi);
Assert.False(m.Seats[1].IsAi);
Assert.Equal(1, m.Seats[0].PlayerId);
Assert.Equal(2, m.Seats[1].PlayerId);
}
[Fact]
public void SingleEnqueue_PollAfterTimeout_Returns1Human1AiMatch()
{
var mm = new Matchmaker(timeoutTicks: 10);
mm.Enqueue(pid: 7, gameId: "rps", version: 1, playerCount: 2, tick: 0);
// Not timed out yet
var early = mm.Poll(currentTick: 9);
Assert.Empty(early);
// Now timed out (currentTick - enqueuedTick > timeoutTicks → 11 - 0 = 11 > 10)
var matches = mm.Poll(currentTick: 10);
Assert.Single(matches);
var m = matches[0];
Assert.Equal(2, m.Seats.Count);
// First seat is the real player
Assert.False(m.Seats[0].IsAi);
Assert.Equal(7, m.Seats[0].PlayerId);
// Second seat is AI with negative pid
Assert.True(m.Seats[1].IsAi);
Assert.True(m.Seats[1].PlayerId < 0);
}
[Fact]
public void NotFull_NotTimedOut_Poll_ReturnsEmpty()
{
var mm = new Matchmaker(timeoutTicks: 100);
mm.Enqueue(pid: 3, gameId: "rps", version: 1, playerCount: 2, tick: 5);
var matches = mm.Poll(currentTick: 50); // 50-5=45 <= 100, not timed out, not full
Assert.Empty(matches);
}
[Fact]
public void PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch()
{
// playerCount=1 应立即成团(Matchmaker 仅按 playerCount 分桶,不加载具体游戏)
var mm = new Matchmaker(timeoutTicks: 100);
mm.Enqueue(pid: 5, gameId: "solo1p", version: 1, playerCount: 1, tick: 0);
var matches = mm.Poll(currentTick: 0);
Assert.Single(matches);
Assert.Single(matches[0].Seats);
Assert.False(matches[0].Seats[0].IsAi);
}
[Fact]
public void Remove_DequeuesWaiter_SoNoMatchForms()
{
var mm = new Matchmaker(timeoutTicks: 100);
mm.Enqueue(1, "rps", 1, 2, tick: 0);
Assert.True(mm.Remove(1));
mm.Enqueue(2, "rps", 1, 2, tick: 0); // 只剩玩家2,凑不齐2人
Assert.Empty(mm.Poll(1)); // 未满未超时 → 无对局
Assert.False(mm.Remove(999)); // 不存在的 pid
}
}
}
@@ -0,0 +1,83 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class RoomOutputSinkTests
{
[Fact]
public void Broadcast_WrapsInGameChannelFrame_ToConnectedPlayers()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
sm.OnConnect(1, c1);
sm.OnConnect(2, c2);
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.Broadcast(new NetMessage(7, new byte[] { 9, 9 }));
Assert.Single(c1.Sent);
Assert.Single(c2.Sent);
var f = FrameCodec.Decode(c1.Sent[0]);
Assert.Equal(Channel.Game, f.Channel);
Assert.Equal((ushort)7, f.Opcode);
Assert.Equal(new byte[] { 9, 9 }, f.Payload);
}
[Fact]
public void Broadcast_SkipsDisconnectedPlayer()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnConnect(2, new FakeConnection(2));
sm.OnDisconnect(2, sm.GetConnection(2), 0); // 玩家2 离线
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.Broadcast(new NetMessage(1, new byte[0]));
Assert.Single(c1.Sent); // 仅在线玩家收到,不抛异常
}
[Fact]
public void SendTo_OnlyTargetPlayer()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
sm.OnConnect(1, c1);
sm.OnConnect(2, c2);
var sink = new RoomOutputSink("r1", new List<int> { 1, 2 }, sm);
sink.SendTo(2, new NetMessage(5, new byte[] { 1 }));
Assert.Empty(c1.Sent);
Assert.Single(c2.Sent);
}
[Fact]
public void Broadcast_AfterReconnect_GoesToNewConnection()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
sm.OnConnect(1, c1);
var sink = new RoomOutputSink("r1", new List<int> { 1 }, sm);
sink.Broadcast(new NetMessage(1, new byte[] { 1 }));
Assert.Single(c1.Sent);
// 断线后重连到新连接
sm.OnDisconnect(1, c1, 0);
var c2 = new FakeConnection(1);
sm.OnConnect(1, c2);
sink.Broadcast(new NetMessage(1, new byte[] { 2 }));
Assert.Single(c1.Sent); // 旧连接不再收到
Assert.Single(c2.Sent); // 新连接收到
}
}
}
+200
View File
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class RpsEndToEndTests
{
// RPS ChoiceOpcode = 1 (same as SnapshotOpcode from RpsServerRoom)
private const ushort RpsChoiceOpcode = 1;
private static Frame RpsJoinFrame()
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());
private static Frame RpsChoiceFrame(byte choice)
{
var w = new PacketWriter();
w.WriteByte(choice);
return new Frame(Channel.Game, RpsChoiceOpcode, w.ToArray());
}
/// <summary>
/// Two real clients match → both receive MatchFound → play choices → both receive RoomEnd
/// Uses large logicalDt so 60s RPS finishes in ~6 ticks (≈120ms wall-clock with 20ms interval)
/// </summary>
[Fact]
public async Task TwoHumans_MatchAndPlayToRoomEnd()
{
var host = new GameServerHost();
// matchTimeoutTicks=200 (generous, will fill by count=2)
// logicalDt=11f: each tick advances 11 logical seconds → ~6 ticks > 60s
await host.StartAsync(TestGames.Root, tickIntervalMs: 20,
reconnectWindowTicks: 100, matchTimeoutTicks: 200, logicalDt: 11f);
try
{
using var client1 = new WsTestClient();
using var client2 = new WsTestClient();
await client1.ConnectAsync(host.WsBaseUrl, pid: 1);
await client2.ConnectAsync(host.WsBaseUrl, pid: 2);
await client1.SendFrameAsync(RpsJoinFrame());
await client2.SendFrameAsync(RpsJoinFrame());
// Both clients need to collect frames until MatchFound, then send choices, then wait for RoomEnd
bool c1MatchFound = false, c2MatchFound = false;
bool c1RoomEnd = false, c2RoomEnd = false;
string c1RoomId = null, c2RoomId = null;
RoomEndMsg c1End = null, c2End = null;
// Receive frames from both clients concurrently
var t1 = Task.Run(async () =>
{
for (int i = 0; i < 60 && !c1RoomEnd; i++)
{
Frame f;
try { f = await client1.ReceiveFrameAsync(3000); }
catch (TimeoutException) { break; }
catch (Exception) { break; }
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
{
c1MatchFound = true;
var mf = MatchFoundMsg.Decode(f.Payload);
c1RoomId = mf.RoomId;
// Send Rock choice after matched
await client1.SendFrameAsync(RpsChoiceFrame(1));
}
else if (f.Channel == Channel.Game)
{
// Got a snapshot; keep sending Rock in case we're in Choosing phase
await client1.SendFrameAsync(RpsChoiceFrame(1));
}
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
{
c1End = RoomEndMsg.Decode(f.Payload);
c1RoomEnd = true;
}
}
});
var t2 = Task.Run(async () =>
{
for (int i = 0; i < 60 && !c2RoomEnd; i++)
{
Frame f;
try { f = await client2.ReceiveFrameAsync(3000); }
catch (TimeoutException) { break; }
catch (Exception) { break; }
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
{
c2MatchFound = true;
var mf = MatchFoundMsg.Decode(f.Payload);
c2RoomId = mf.RoomId;
await client2.SendFrameAsync(RpsChoiceFrame(2)); // Paper
}
else if (f.Channel == Channel.Game)
{
await client2.SendFrameAsync(RpsChoiceFrame(2)); // Paper
}
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
{
c2End = RoomEndMsg.Decode(f.Payload);
c2RoomEnd = true;
}
}
});
await Task.WhenAll(t1, t2);
Assert.True(c1MatchFound, "client1 should receive MatchFound");
Assert.True(c2MatchFound, "client2 should receive MatchFound");
Assert.NotNull(c1RoomId);
Assert.Equal(c1RoomId, c2RoomId); // Both in same room
Assert.True(c1RoomEnd, "client1 should receive RoomEnd");
Assert.True(c2RoomEnd, "client2 should receive RoomEnd");
// 结算结果:blob 非占位(RPS 填入 UTF-8 比分文本),双端一致
Assert.NotNull(c1End);
Assert.NotNull(c2End);
Assert.True(c1End.ResultBlob != null && c1End.ResultBlob.Length > 0, "RoomEnd 应携带结算 blob");
Assert.Equal(c1End.WinnerPlayerId, c2End.WinnerPlayerId);
Assert.Equal(c1End.ResultBlob, c2End.ResultBlob); // 网关按房间统一下发同一结算
Assert.Contains(":", System.Text.Encoding.UTF8.GetString(c1End.ResultBlob)); // 比分/平局文本
await client1.CloseAsync();
await client2.CloseAsync();
}
finally { await host.StopAsync(); }
}
/// <summary>
/// Single client + small matchTimeoutTicks → AI fill → client receives MatchFound with AI player → RoomEnd
/// </summary>
[Fact]
public async Task SingleClient_AiFill_MatchAndPlayToRoomEnd()
{
var host = new GameServerHost();
// matchTimeoutTicks=3: after ~3 ticks (60ms at 20ms interval), AI fills in
// logicalDt=11f: large logical time step to finish RPS in ~6 ticks
await host.StartAsync(TestGames.Root, tickIntervalMs: 20,
reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 11f);
try
{
using var client = new WsTestClient();
await client.ConnectAsync(host.WsBaseUrl, pid: 7);
await client.SendFrameAsync(RpsJoinFrame());
bool matchFound = false;
bool hasAiPlayer = false;
bool roomEnd = false;
RoomEndMsg end = null;
for (int i = 0; i < 60 && !roomEnd; i++)
{
Frame f;
try { f = await client.ReceiveFrameAsync(3000); }
catch (TimeoutException) { break; }
catch (Exception) { break; }
if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.MatchFound)
{
matchFound = true;
var mf = MatchFoundMsg.Decode(f.Payload);
Assert.Equal(2, mf.Players.Count);
foreach (var p in mf.Players)
if (p.IsAI) { hasAiPlayer = true; Assert.True(p.PlayerId < 0); }
// Send Rock choice after matched
await client.SendFrameAsync(RpsChoiceFrame(1));
}
else if (f.Channel == Channel.Game)
{
// Keep sending Rock on each snapshot
await client.SendFrameAsync(RpsChoiceFrame(1));
}
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
{
end = RoomEndMsg.Decode(f.Payload);
roomEnd = true;
}
}
Assert.True(matchFound, "Should receive MatchFound (with AI fill)");
Assert.True(hasAiPlayer, "MatchFound should include an AI player");
Assert.True(roomEnd, "Should receive RoomEnd after AI-fill game settles");
Assert.NotNull(end);
Assert.True(end.ResultBlob != null && end.ResultBlob.Length > 0, "AI 对局的 RoomEnd 也应携带结算 blob");
await client.CloseAsync();
}
finally { await host.StopAsync(); }
}
}
}
@@ -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)");
}
}
}
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.PublishTool;
using XWorld.Server.Host;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
/// <summary>
/// Integration tests: ServerLoop with publicKeyPem enforces signature verification end-to-end.
/// TDD: these tests are written RED-first, then made GREEN by wiring publicKeyPem through
/// ServerLoop ctor → GameModuleLoader.
/// </summary>
public class ServerLoopSignatureEnforcementTests : IDisposable
{
private readonly string _tempRoot;
private readonly string _gameDir; // {tempRoot}/rps/1/
public ServerLoopSignatureEnforcementTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "sig_enforcement_" + Guid.NewGuid().ToString("N"));
_gameDir = Path.Combine(_tempRoot, "rps", "1");
Directory.CreateDirectory(_gameDir);
// Copy rps/1 files from the shared TestGames root (built by build-test-games.sh)
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
File.Copy(Path.Combine(src, name), Path.Combine(_gameDir, name), overwrite: true);
}
public void Dispose()
{
if (!Directory.Exists(_tempRoot)) return;
// Unload ALCs and wait for Windows file locks to release
for (int i = 0; i < 5; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
try { Directory.Delete(_tempRoot, true); }
catch (UnauthorizedAccessException) { /* Windows file lock: GC will release eventually */ }
}
private static (string pubPem, string privPem) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem());
}
/// <summary>
/// Write files.txt + files.txt.sig into _gameDir using the given private key.
/// </summary>
private void WriteSignature(string privPem)
{
var fm = new FilesManifest();
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
{
string p = Path.Combine(_gameDir, name);
byte[] b = File.ReadAllBytes(p);
fm.Add(name, Md5Util.OfBytes(b), b.Length);
}
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
File.WriteAllBytes(Path.Combine(_gameDir, "files.txt.sig"),
ManifestSigner.Sign(fm.Digest(), privPem));
}
// --- helpers mirroring ServerLoopMatchmakingTests ---
private static byte[] RpsJoinFrame()
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode()));
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 HasError(FakeConnection c) =>
Frames(c).Exists(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
/// <summary>
/// Signed module + correct public key → 2 players receive MatchFound (verification passes).
/// </summary>
[Fact]
public void SignedModule_WithCorrectKey_PlayersGetMatchFound()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// Large matchTimeoutTicks so the 2 humans match by player-count before any AI fill
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-ok]"),
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
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()) });
// One DrainAndTick: both players enqueued → matchmaker sees count=2 >= playerCount=2
// → forms match → CreateRoomForSeats → GameModuleLoader.Load (verifies signature: OK)
// → room created → both players receive MatchFound
loop.DrainAndTick(1f);
Assert.True(HasMatchFound(c1), "c1 should receive MatchFound when signature is valid");
Assert.True(HasMatchFound(c2), "c2 should receive MatchFound when signature is valid");
}
/// <summary>
/// Tampered module + correct public key → signature verification fails during CreateRoom
/// → CreateRoomForSeats catches and sends Error to players → neither gets MatchFound.
/// </summary>
[Fact]
public void TamperedModule_WithKey_PlayersGetErrorNotMatchFound()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// Tamper RPS.Core.dll: flip first byte → md5 changes → manifest md5 check fails
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF;
File.WriteAllBytes(coreDll, bytes);
// Large matchTimeoutTicks so 2 humans match before AI fill
var loop = new ServerLoop(_tempRoot, new ConsoleLogger("[sig-bad]"),
reconnectWindowTicks: 100, matchTimeoutTicks: 10000, publicKeyPem: pubPem);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
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()) });
// One DrainAndTick: match forms → CreateRoomForSeats → GameModuleLoader.Load
// (detects tampered dll via md5 mismatch) → throws → caught by CreateRoomForSeats
// → sends Error (code=1) to both real players
loop.DrainAndTick(1f);
// Primary invariant: no MatchFound (room was never created)
Assert.False(HasMatchFound(c1), "c1 must NOT receive MatchFound for a tampered module");
Assert.False(HasMatchFound(c2), "c2 must NOT receive MatchFound for a tampered module");
// Secondary invariant: at least one player receives an Error frame
// (CreateRoomForSeats sends Error to all realPids on load failure)
Assert.True(HasError(c1) || HasError(c2),
"At least one player should receive an Error frame when module load fails");
}
}
}
+168
View File
@@ -0,0 +1,168 @@
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 ServerLoopTests
{
private static ServerLoop NewLoop(long reconnectWindow = 100)
=> new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);
private static byte[] JoinFrame(string gameId, int version)
=> FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));
private static Frame LobbyJoinFrame(string name)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyJoin,
new LobbyJoinMsg { Name = name, Figure = 1 }.Encode());
private static Frame LobbyMoveFrame(float x, float z, bool moving)
=> new Frame(Channel.Framework, (ushort)FrameworkOpcode.LobbyMove,
new LobbyMoveMsg
{
X = x,
Y = 0f,
Z = z,
DirX = 1f,
DirZ = 0f,
Moving = moving
}.Encode());
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;
}
[Fact]
public void Heartbeat_IsEchoed()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
loop.Submit(new FrameCommand { PlayerId = 1,
Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });
loop.DrainAndTick(0f);
var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
Assert.NotEqual(default, echo);
Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
}
[Fact]
public void LobbyMove_IsBroadcastToAllLobbyPlayers()
{
var loop = NewLoop();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyJoinFrame("editor") });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = LobbyJoinFrame("android") });
loop.DrainAndTick(0f);
c1.Sent.Clear();
c2.Sent.Clear();
loop.Submit(new FrameCommand { PlayerId = 1, Frame = LobbyMoveFrame(3f, 4f, true) });
loop.DrainAndTick(0f);
Frame moveToSelf = Frames(c1).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
Frame moveToOther = Frames(c2).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.LobbyMove);
Assert.NotEqual(default, moveToSelf);
Assert.NotEqual(default, moveToOther);
LobbyMoveMsg decoded = LobbyMoveMsg.Decode(moveToOther.Payload);
Assert.Equal(1, decoded.PlayerId);
Assert.Equal(3f, decoded.X);
Assert.Equal(4f, decoded.Z);
Assert.True(decoded.Moving);
}
[Fact]
public void Reconnect_ReroutesRoomOutputToNewConnection()
{
var loop = NewLoop(reconnectWindow: 100);
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
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(JoinFrame("rps", 1)) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f); // 2 人成团:c1/c2 收到 MatchFound + 快照
Assert.True(c1.Sent.Count >= 2);
// pid=1 断线后重连到新连接 c1b
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
var c1b = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b
Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
}
[Fact]
public void ExpiredSession_IsSwept()
{
var loop = NewLoop(reconnectWindow: 2);
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // _tick: 0→1
loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
loop.DrainAndTick(1f); // _tick→33-1=2,不 >2,保留
loop.DrainAndTick(1f); // _tick→44-1=3 >2 → 过期清除
Assert.False(loop.HasSession(1));
}
[Fact]
public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
{
var loop = NewLoop();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(2);
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(JoinFrame("rps", 1)) });
loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f); // 2 人入房
// 已在房间内再次 join → 回 Error
loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
loop.DrainAndTick(1f);
Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
}
[Fact]
public void GameFrame_WithNoRoom_IsIgnoredSafely()
{
var loop = NewLoop();
var c = new FakeConnection(1);
loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
var w = new PacketWriter(); w.WriteVarInt(3);
loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex); // 无房间的游戏帧被安全忽略,不抛
Assert.Empty(c.Sent); // 无任何输出
}
[Fact]
public void MatchRequest_WithNoSession_IsIgnoredSafely()
{
var loop = NewLoop();
// 未发 ConnectCommand,直接来一个 join 帧
loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
var ex = Record.Exception(() => loop.DrainAndTick(1f));
Assert.Null(ex);
Assert.False(loop.HasSession(99));
}
}
}
@@ -0,0 +1,73 @@
using Xunit;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class SessionManagerTests
{
[Fact]
public void Connect_NewPid_CreatesConnectedSession()
{
var sm = new SessionManager();
var c = new FakeConnection(1);
sm.OnConnect(1, c);
Assert.True(sm.IsConnected(1));
Assert.Same(c, sm.GetConnection(1));
}
[Fact]
public void Reconnect_SamePid_RebindsToNewConnection()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnDisconnect(1, c1, currentTick: 5);
Assert.False(sm.IsConnected(1));
Assert.Null(sm.GetConnection(1));
sm.OnConnect(1, c2); // 重连
Assert.True(sm.IsConnected(1));
Assert.Same(c2, sm.GetConnection(1)); // 路由到新连接
}
[Fact]
public void StaleDisconnect_AfterReconnect_DoesNotClobber()
{
var sm = new SessionManager();
var c1 = new FakeConnection(1);
var c2 = new FakeConnection(1);
sm.OnConnect(1, c1);
sm.OnConnect(1, c2); // 已重连到 c2
sm.OnDisconnect(1, c1, currentTick: 9); // 旧连接 c1 的迟到断线不应清掉 c2
Assert.True(sm.IsConnected(1));
Assert.Same(c2, sm.GetConnection(1));
}
[Fact]
public void RoomBinding_RoundTrips()
{
var sm = new SessionManager();
sm.OnConnect(1, new FakeConnection(1));
sm.Get(1).RoomId = "r1";
Assert.Equal("r1", sm.Get(1).RoomId);
}
[Fact]
public void SweepExpired_RemovesSessionsPastWindow()
{
var sm = new SessionManager();
var conn = new FakeConnection(1);
sm.OnConnect(1, conn);
sm.OnDisconnect(1, conn, currentTick: 0);
var none = sm.SweepExpired(currentTick: 3, windowTicks: 5);
Assert.Empty(none);
Assert.NotNull(sm.Get(1));
var removed = sm.SweepExpired(currentTick: 6, windowTicks: 5); // 6-0 > 5
Assert.Single(removed);
Assert.Null(sm.Get(1));
}
}
}
@@ -0,0 +1,66 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class StaticFileServerTests
{
[Fact]
public async Task ServesExistingFile_200_WithExactBytes()
{
string root = Path.Combine(Path.GetTempPath(), "xw-cdn-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(root, "minigame", "rps", "2"));
byte[] payload = new byte[] { 1, 2, 3, 4, 250, 99, 0, 7 };
File.WriteAllBytes(Path.Combine(root, "minigame", "rps", "2", "files.txt"), payload);
var cdn = new StaticFileServer(root, port: 0);
await cdn.StartAsync();
try
{
using var http = new HttpClient();
HttpResponseMessage res = await http.GetAsync($"{cdn.HttpBaseUrl}/minigame/rps/2/files.txt");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
byte[] got = await res.Content.ReadAsByteArrayAsync();
Assert.Equal(payload, got);
}
finally
{
await cdn.StopAsync();
try { Directory.Delete(root, true); } catch { }
}
}
[Fact]
public async Task MissingFile_404()
{
string root = Path.Combine(Path.GetTempPath(), "xw-cdn-" + Guid.NewGuid().ToString("N"));
var cdn = new StaticFileServer(root, port: 0);
await cdn.StartAsync();
try
{
using var http = new HttpClient();
HttpResponseMessage res = await http.GetAsync($"{cdn.HttpBaseUrl}/minigame/nope/1/game.json");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
finally
{
await cdn.StopAsync();
try { Directory.Delete(root, true); } catch { }
}
}
[Fact]
public void LanIp_ReturnsNonLoopbackIPv4_OrFallback()
{
string ip = LanIp.Resolve();
Assert.False(string.IsNullOrWhiteSpace(ip));
Assert.True(System.Net.IPAddress.TryParse(ip, out var parsed));
Assert.Equal(System.Net.Sockets.AddressFamily.InterNetwork, parsed.AddressFamily);
}
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace XWorld.Server.Gateway.Tests
{
internal sealed class FakeConnection : IClientConnection
{
public int PlayerId { get; }
public List<byte[]> Sent { get; } = new List<byte[]>();
public FakeConnection(int playerId) { PlayerId = playerId; }
public void Send(byte[] frame) => Sent.Add(frame);
}
}
+11
View File
@@ -0,0 +1,11 @@
using System;
using System.IO;
namespace XWorld.Server.Gateway.Tests
{
internal static class TestGames
{
// 前置条件:先运行 build-test-games.sh 生成 Server.Host.Tests/TestGames(本工程复用之)
public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
}
}
@@ -0,0 +1,51 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway.Tests
{
// 集成测试用 WS 客户端:连接、发帧、带超时收一帧并解码
internal sealed class WsTestClient : IDisposable
{
private readonly ClientWebSocket _ws = new ClientWebSocket();
public async Task ConnectAsync(string wsBaseUrl, int pid)
{
await _ws.ConnectAsync(new Uri($"{wsBaseUrl}/ws?pid={pid}"), CancellationToken.None);
}
public async Task SendFrameAsync(Frame frame)
{
byte[] bytes = FrameCodec.Encode(frame);
await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None);
}
// 在 timeout 内收一条 WS 消息并解码为 Frame;超时抛 TimeoutException
public async Task<Frame> ReceiveFrameAsync(int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var buf = new byte[8192];
using var ms = new MemoryStream();
WebSocketReceiveResult res;
do
{
res = await _ws.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token);
if (res.MessageType == WebSocketMessageType.Close)
throw new InvalidOperationException("server closed");
ms.Write(buf, 0, res.Count);
} while (!res.EndOfMessage);
return FrameCodec.Decode(ms.ToArray());
}
public async Task CloseAsync()
{
try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); }
catch { /* 忽略 */ }
}
public void Dispose() => _ws.Dispose();
}
}
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
// 一个 WebSocket 连接:收循环解码帧→Submit;发循环排空出站队列→SendAsync(每 socket 仅一个发循环,发送串行)
public sealed class Connection : IClientConnection
{
public int PlayerId { get; }
private readonly WebSocket _socket;
private readonly ServerLoop _loop;
private readonly ChannelsNS.Channel<byte[]> _outbound = ChannelsNS.Channel.CreateUnbounded<byte[]>();
public Connection(int playerId, WebSocket socket, ServerLoop loop)
{
PlayerId = playerId; _socket = socket; _loop = loop;
}
public void Send(byte[] frame) => _outbound.Writer.TryWrite(frame);
public async Task RunAsync(CancellationToken ct)
{
var sendTask = SendLoopAsync(ct);
try { await ReceiveLoopAsync(ct).ConfigureAwait(false); }
finally
{
_outbound.Writer.TryComplete();
await sendTask.ConfigureAwait(false);
}
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buf = new byte[8192];
using var ms = new MemoryStream();
try
{
while (_socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
ms.SetLength(0);
WebSocketReceiveResult res;
do
{
res = await _socket.ReceiveAsync(new ArraySegment<byte>(buf), ct).ConfigureAwait(false);
if (res.MessageType == WebSocketMessageType.Close) return;
ms.Write(buf, 0, res.Count);
} while (!res.EndOfMessage);
Frame frame;
try { frame = FrameCodec.Decode(ms.ToArray()); }
catch (FormatException) { continue; } // 畸形帧:丢弃,不拖垮连接
_loop.Submit(new FrameCommand { PlayerId = PlayerId, Frame = frame });
}
}
catch (OperationCanceledException) { } // 主机关停/请求中止
catch (WebSocketException) { } // 对端突然断开(TCP 掉线)
}
private async Task SendLoopAsync(CancellationToken ct)
{
try
{
await foreach (var frame in _outbound.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
if (_socket.State != WebSocketState.Open) break;
await _socket.SendAsync(new ArraySegment<byte>(frame),
WebSocketMessageType.Binary, true, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (WebSocketException) { } // 对端已断,停止发送
}
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
public sealed class DevDiscoveryResponder
{
private readonly DevDiscoveryReply _template;
private readonly string _token;
private readonly UdpClient _udp;
private CancellationTokenSource _cts;
private Task _loop;
public int Port { get; }
public DevDiscoveryResponder(string serverName, string gatewayUrl, string resourceBaseUrl,
string token, int listenPort = DevDiscovery.Port)
{
if (string.IsNullOrEmpty(token)) throw new ArgumentException("token cannot be empty", nameof(token));
_token = token;
_template = new DevDiscoveryReply
{
ProtoVersion = DevDiscovery.ProtoVersion,
ServerName = serverName ?? "dev-server",
GatewayUrl = gatewayUrl ?? "",
ResourceBaseUrl = resourceBaseUrl ?? "",
Token = token,
};
_udp = new UdpClient(AddressFamily.InterNetwork);
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port;
}
public void Start()
{
if (_loop != null) return;
_cts = new CancellationTokenSource();
_loop = ReceiveLoopAsync(_cts.Token);
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
using (ct.Register(() => { try { _udp.Close(); } catch { } }))
{
while (!ct.IsCancellationRequested)
{
UdpReceiveResult res;
try { res = await _udp.ReceiveAsync().ConfigureAwait(false); }
catch (ObjectDisposedException) { break; }
catch (SocketException) { break; }
if (!DevDiscoveryCodec.TryDecodeQuery(res.Buffer, out var q)) continue;
if (q.ProtoVersion != DevDiscovery.ProtoVersion) continue;
if (!string.Equals(q.Token, _token, StringComparison.Ordinal)) continue;
DevDiscoveryReply reply = _template;
reply.Nonce = q.Nonce;
byte[] bytes = DevDiscoveryCodec.EncodeReply(reply);
try { await _udp.SendAsync(bytes, bytes.Length, res.RemoteEndPoint).ConfigureAwait(false); }
catch (ObjectDisposedException) { break; }
catch (SocketException) { }
}
}
}
public async Task StopAsync()
{
if (_cts == null) return;
_cts.Cancel();
try { await _loop.ConfigureAwait(false); }
catch { }
_cts.Dispose();
_cts = null;
_loop = null;
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using System;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Auth;
namespace XWorld.Server.Gateway
{
// 建 Kestrel + /ws 端点,把 socket 接入 ServerLoop。Start 返回实际监听地址(端口由系统分配)。
public sealed class GameServerHost
{
private WebApplication _app;
private ServerLoop _loop;
private CancellationTokenSource _cts;
private Task _loopTask;
private DevDiscoveryResponder _discovery;
private AccountStore _accounts;
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
public int DevDiscoveryPort => _discovery?.Port ?? 0;
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100,
long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0,
bool bindAllInterfaces = false,
string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null,
int devDiscoveryPort = DevDiscovery.Port,
byte[] authSecret = null, string authDbPath = null)
{
_loop = new ServerLoop(gamesRoot, new ConsoleLogger("[loop]"), reconnectWindowTicks, matchTimeoutTicks, publicKeyPem);
_cts = new CancellationTokenSource();
float dt = logicalDt ?? tickIntervalMs / 1000f;
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o =>
o.Listen(bindAllInterfaces ? IPAddress.Any : IPAddress.Loopback, listenPort)); // 0 = 临时端口
TokenService tokens = null;
if (authSecret != null)
{
tokens = new TokenService(authSecret);
_accounts = new AccountStore(string.IsNullOrEmpty(authDbPath)
? "Data Source=accounts.db" : authDbPath);
}
var app = builder.Build();
app.UseWebSockets();
if (tokens != null)
AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7));
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path == "/ws" && ctx.WebSockets.IsWebSocketRequest)
{
int pid;
if (tokens != null)
{
string token = ctx.Request.Query["token"];
if (!tokens.Verify(token, out pid))
{
ctx.Response.StatusCode = 401;
return;
}
}
else if (!int.TryParse(ctx.Request.Query["pid"], out pid))
{
ctx.Response.StatusCode = 400;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var conn = new Connection(pid, ws, _loop);
_loop.Submit(new ConnectCommand { PlayerId = pid, Connection = conn });
try { await conn.RunAsync(ctx.RequestAborted); }
catch (OperationCanceledException) { } // 关停/中止:正常收束
finally
{
if (ws.State == WebSocketState.Open)
{
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "server stopping", CancellationToken.None); }
catch { /* 关闭握手尽力而为 */ }
}
_loop.Submit(new DisconnectCommand { PlayerId = pid, Connection = conn });
}
}
else
{
await next();
}
});
await app.StartAsync();
_app = app;
var addresses = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses;
string http = addresses.FirstOrDefault()
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
WsBaseUrl = http.Replace("http://", "ws://");
if (!string.IsNullOrEmpty(devDiscoveryToken))
{
string adGw = advertiseGatewayUrl;
if (string.IsNullOrEmpty(adGw))
{
// 绑所有网卡时 WsBaseUrl 形如 ws://0.0.0.0:port,对内网其它设备无意义 ——
// 用本机真实 LAN IP 广而告之(仿 C++ XBroadcastServer.GetLocalIPAddress)。
if (bindAllInterfaces)
adGw = $"ws://{LanIp.Resolve()}:{new Uri(WsBaseUrl).Port}/ws";
else
adGw = WsBaseUrl + "/ws";
}
_discovery = new DevDiscoveryResponder(
Environment.MachineName, adGw, advertiseResourceBaseUrl ?? "", devDiscoveryToken, devDiscoveryPort);
_discovery.Start();
}
_loopTask = _loop.RunAsync(tickIntervalMs, dt, _cts.Token);
}
public async Task StopAsync()
{
if (_discovery != null)
{
await _discovery.StopAsync();
_discovery = null;
}
_cts.Cancel();
try { await _loopTask; } catch { /* 忽略取消 */ }
await _app.StopAsync();
await _app.DisposeAsync();
_accounts?.Dispose();
_accounts = null;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Gateway</AssemblyName>
<RootNamespace>XWorld.Server.Gateway</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Server.Host\Server.Host.csproj" />
<ProjectReference Include="..\Auth\Auth.csproj" />
</ItemGroup>
</Project>
+10
View File
@@ -0,0 +1,10 @@
namespace XWorld.Server.Gateway
{
// actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录
public interface IClientConnection
{
int PlayerId { get; }
// frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。
void Send(byte[] frame);
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace XWorld.Server.Gateway
{
// 取本机内网主 IPv4,用于广播 advertise(仿 C++ XBroadcastServer.GetLocalIPAddress)。
// 优先选 Up、非 loopback、非隧道、带网关的网卡上的 IPv4;都没有则回退 127.0.0.1。
public static class LanIp
{
public static string Resolve()
{
// 1) 优先:有默认网关的活动网卡(最可能是真实内网口)
string withGateway = PickFromNics(requireGateway: true);
if (withGateway != null) return withGateway;
// 2) 退一步:任意 Up 的非 loopback/隧道网卡上的私有 IPv4
string anyUp = PickFromNics(requireGateway: false);
if (anyUp != null) return anyUp;
// 3) 兜底:DNS 解析本机名里的第一个 IPv4
foreach (IPAddress addr in Dns.GetHostAddresses(Dns.GetHostName()))
{
if (addr.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(addr))
return addr.ToString();
}
return "127.0.0.1";
}
private static string PickFromNics(bool requireGateway)
{
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up) continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
IPInterfaceProperties props = nic.GetIPProperties();
if (requireGateway && !props.GatewayAddresses.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
continue;
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
IPAddress ip = ua.Address;
if (ip.AddressFamily != AddressFamily.InterNetwork) continue;
if (IPAddress.IsLoopback(ip)) continue;
return ip.ToString();
}
}
return null;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
public sealed class Matchmaker
{
public sealed class Seat { public int PlayerId; public bool IsAi; }
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
private readonly long _timeoutTicks;
private int _aiSeq;
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
private static string Key(string g, int v) => $"{g}@{v}";
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
{
string k = Key(gameId, version);
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
}
// 从等待队列移除某玩家(断线时调用);返回是否移除
public bool Remove(int pid)
{
foreach (var kv in _buckets)
{
var ws = kv.Value.Waiters;
for (int i = 0; i < ws.Count; i++)
if (ws[i].Pid == pid) { ws.RemoveAt(i); return true; }
}
return false;
}
// 返回本次可成形的对局(凑齐或超时 AI 补足)
public IReadOnlyList<Match> Poll(long currentTick)
{
List<Match> formed = null;
foreach (var kv in _buckets)
{
var b = kv.Value;
string[] gv = kv.Key.Split('@');
while (b.Waiters.Count > 0 &&
(b.Waiters.Count >= b.PlayerCount ||
currentTick - b.Waiters[0].EnqueuedTick >= _timeoutTicks))
{
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
for (int i = 0; i < take; i++)
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false });
b.Waiters.RemoveRange(0, take);
while (m.Seats.Count < b.PlayerCount)
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
(formed ??= new List<Match>()).Add(m);
}
}
return formed ?? (IReadOnlyList<Match>)System.Array.Empty<Match>();
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 房间广播 → 解析各玩家当前连接(重连后自动指向新连接)→ 包成 Game 通道帧入对应出站队列
public sealed class RoomOutputSink : IRoomOutput
{
private readonly string _roomId; // 保留作诊断/日志标识
private readonly IReadOnlyList<int> _players;
private readonly SessionManager _sessions;
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
{
_roomId = roomId; _players = players; _sessions = sessions;
}
public void Broadcast(NetMessage message)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
foreach (var pid in _players)
_sessions.GetConnection(pid)?.Send(frame);
}
public void SendTo(int playerId, NetMessage message)
{
var conn = _sessions.GetConnection(playerId);
if (conn != null)
conn.Send(FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
}
}
}
+12
View File
@@ -0,0 +1,12 @@
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
public abstract class ServerCommand { public int PlayerId; }
public sealed class ConnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class DisconnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class FrameCommand : ServerCommand { public Frame Frame; }
}
+433
View File
@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 单线程 actor:独占 RoomHost 与 SessionManager;命令入队、周期性排空+推进。
public sealed class ServerLoop
{
private readonly RoomHost _host;
private readonly SessionManager _sessions = new SessionManager();
private readonly ILogger _logger;
private readonly long _reconnectWindowTicks;
private readonly string _gamesRoot;
private readonly Matchmaker _matchmaker;
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
private readonly HashSet<int> _queuedPids = new HashSet<int>(); // pids currently in matchmaker queue
private readonly Random _random = new Random(1);
private long _tick;
private int _roomSeq;
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
long matchTimeoutTicks = 150, string publicKeyPem = null)
{
_logger = logger;
_reconnectWindowTicks = reconnectWindowTicks;
_gamesRoot = gamesRoot;
_host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(publicKeyPem), gamesRoot), logger);
_matchmaker = new Matchmaker(matchTimeoutTicks);
}
// 线程安全:网络线程调用,仅入队
public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);
// 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
public bool HasSession(int pid) => _sessions.Get(pid) != null;
// 测试直调;真实循环周期性调它。
// 两步排空:先处理所有 Connect/Disconnect/Framework(含 MatchRequest 入队),
// 再 Poll 匹配器建房,再处理 Game 帧(此时 RoomId 已就位)。
public void DrainAndTick(float dt)
{
// 第一遍:收集所有命令,立刻处理非游戏帧(connect/disconnect/framework
var deferred = new List<FrameCommand>(); // 本批游戏帧,延后到建房后处理
while (_cmds.Reader.TryRead(out var cmd))
{
if (cmd is FrameCommand fc && fc.Frame.Channel == Channel.Game)
deferred.Add(fc);
else
Process(cmd);
}
// Poll 匹配器 → 对本 tick 内已凑齐或超时的队列建房
var matches = _matchmaker.Poll(_tick);
for (int i = 0; i < matches.Count; i++)
{
_logger.Info($"match formed {matches[i].GameId}@{matches[i].Version} seats={matches[i].Seats.Count} tick={_tick}");
CreateRoomForSeats(matches[i]);
}
// 第二遍:处理本批游戏帧(此时 RoomId 已就位)
for (int i = 0; i < deferred.Count; i++)
{
var fc = deferred[i];
Route(fc.PlayerId, fc.Frame);
}
_tick++;
var ended = _host.TickAll(dt);
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
var expired = _sessions.SweepExpired(_tick, _reconnectWindowTicks);
for (int i = 0; i < expired.Count; i++)
_logger.Info($"session {expired[i].PlayerId} 重连窗口过期,清除");
}
public async Task RunAsync(int tickIntervalMs, CancellationToken ct)
=> await RunAsync(tickIntervalMs, tickIntervalMs / 1000f, ct);
public async Task RunAsync(int tickIntervalMs, float logicalDt, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
DrainAndTick(logicalDt);
try { await Task.Delay(tickIntervalMs, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void Process(ServerCommand cmd)
{
switch (cmd)
{
case ConnectCommand cc:
_sessions.OnConnect(cc.PlayerId, cc.Connection);
_logger.Info($"session connect pid={cc.PlayerId}");
break;
case DisconnectCommand dc:
_sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick);
_logger.Info($"session disconnect pid={dc.PlayerId}");
if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId);
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
break;
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
}
}
private void Route(int pid, Frame frame)
{
if (frame.Channel == Channel.Framework) HandleFramework(pid, frame);
else
{
var s = _sessions.Get(pid);
if (s?.RoomId != null)
_host.DeliverTo(s.RoomId, pid, new NetMessage(frame.Opcode, frame.Payload));
}
}
private void HandleFramework(int pid, Frame frame)
{
FrameworkOpcode op = (FrameworkOpcode)frame.Opcode;
if (op != FrameworkOpcode.LobbyMove)
{
_logger.Info($"framework pid={pid} op={op} bytes={frame.Payload?.Length ?? 0}");
}
switch (op)
{
case FrameworkOpcode.Heartbeat:
SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
break;
case FrameworkOpcode.MatchRequest:
var req = MatchRequestMsg.Decode(frame.Payload);
EnqueueMatch(pid, req.GameId, req.Version);
break;
case FrameworkOpcode.GameListRequest:
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
break;
case FrameworkOpcode.RandomMatchRequest:
EnqueueRandomMatch(pid);
break;
case FrameworkOpcode.LobbyJoin:
HandleLobbyJoin(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyMove:
HandleLobbyMove(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyLeave:
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
break;
}
}
private void HandleLobbyJoin(int pid, byte[] payload)
{
var req = LobbyJoinMsg.Decode(payload);
string name = string.IsNullOrWhiteSpace(req.Name) ? $"P{pid}" : req.Name;
int figure = req.Figure <= 0 ? 1 : req.Figure;
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
float lane = (_lobbyPlayers.Count % 6) - 2.5f;
player = new LobbyPlayerMsg
{
PlayerId = pid,
Name = name,
Figure = figure,
X = lane * 1.5f,
Y = 0f,
Z = 0f,
DirX = 0f,
DirZ = 1f,
Moving = false
};
_lobbyPlayers[pid] = player;
}
else
{
player.Name = name;
player.Figure = figure;
}
_logger.Info($"lobby join pid={pid} name={name} figure={figure} players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
BroadcastLobbyState();
}
private void HandleLobbyMove(int pid, byte[] payload)
{
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
_logger.Warn($"lobby move ignored pid={pid} reason=not_joined players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
return;
}
var move = LobbyMoveMsg.Decode(payload);
player.X = move.X;
player.Y = move.Y;
player.Z = move.Z;
player.DirX = move.DirX;
player.DirZ = move.DirZ;
player.Moving = move.Moving;
move.PlayerId = pid;
byte[] encoded = move.Encode();
foreach (int targetPid in _lobbyPlayers.Keys)
{
SendFramework(targetPid, FrameworkOpcode.LobbyMove, encoded);
}
}
private void BroadcastLobbyState()
{
if (_lobbyPlayers.Count == 0) return;
var state = new LobbyStateMsg();
foreach (var player in _lobbyPlayers.Values)
{
state.Players.Add(player);
}
byte[] payload = state.Encode();
_logger.Info($"lobby state players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
foreach (int pid in _lobbyPlayers.Keys)
{
SendFramework(pid, FrameworkOpcode.LobbyState, payload);
}
}
private void EnqueueRandomMatch(int pid)
{
var games = BuildGameList().Games;
if (games.Count == 0)
{
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
return;
}
var selected = games[_random.Next(games.Count)];
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
SendFramework(pid, FrameworkOpcode.MatchAssigned,
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode());
EnqueueMatch(pid, selected.GameId, selected.Version);
}
private GameListResponseMsg BuildGameList()
{
var latest = new Dictionary<string, GameInfoMsg>(StringComparer.OrdinalIgnoreCase);
if (!Directory.Exists(_gamesRoot)) return new GameListResponseMsg();
foreach (var gameDir in Directory.GetDirectories(_gamesRoot))
{
foreach (var versionDir in Directory.GetDirectories(gameDir))
{
string manifestPath = Path.Combine(versionDir, "game.json");
if (!File.Exists(manifestPath)) continue;
try
{
var manifest = GameManifest.Load(manifestPath);
if (string.Equals(manifest.GameId, "lobby", StringComparison.OrdinalIgnoreCase)) continue;
if (manifest.PlayerCount < 2 || manifest.PlayerCount > 8) continue;
if (!latest.TryGetValue(manifest.GameId, out var old) || manifest.Version > old.Version)
{
latest[manifest.GameId] = new GameInfoMsg
{
GameId = manifest.GameId,
Version = manifest.Version,
DisplayName = manifest.GameId,
MinPlayers = 2,
MaxPlayers = Math.Max(2, Math.Min(8, manifest.PlayerCount)),
};
}
}
catch (Exception ex)
{
_logger.Warn($"skip invalid game manifest {manifestPath}: {ex.Message}");
}
}
}
var msg = new GameListResponseMsg();
foreach (var game in latest.Values) msg.Games.Add(game);
msg.Games.Sort((a, b) => string.Compare(a.GameId, b.GameId, StringComparison.OrdinalIgnoreCase));
return msg;
}
private void EnqueueMatch(int pid, string gameId, int version)
{
var s = _sessions.Get(pid);
if (s == null) return; // 未建立会话,忽略
if (s.RoomId != null || _queuedPids.Contains(pid))
{
// 已在房间内或已在队列中:拒绝
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
return;
}
// 读取游戏的 playerCount(缓存)
string cacheKey = $"{gameId}@{version}";
if (!_playerCountCache.TryGetValue(cacheKey, out int playerCount))
{
string manifestPath = Path.Combine(_gamesRoot, gameId, version.ToString(), "game.json");
try
{
var manifest = GameManifest.Load(manifestPath);
playerCount = manifest.PlayerCount;
_playerCountCache[cacheKey] = playerCount;
}
catch (Exception ex)
{
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
return;
}
}
_queuedPids.Add(pid);
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
}
private void CreateRoomForSeats(Matchmaker.Match match)
{
string roomId = $"r{++_roomSeq}";
var seats = match.Seats;
int aiCount = 0;
for (int i = 0; i < seats.Count; i++)
{
if (seats[i].IsAi) aiCount++;
}
_logger.Info($"create room {roomId} game={match.GameId}@{match.Version} real={seats.Count - aiCount} ai={aiCount}");
// Build PlayerInfo[] for all seats
var playerInfos = new PlayerInfo[seats.Count];
var realPids = new List<int>();
for (int i = 0; i < seats.Count; i++)
{
var seat = seats[i];
playerInfos[i] = new PlayerInfo
{
PlayerId = seat.PlayerId,
Name = seat.IsAi ? "AI" : $"P{seat.PlayerId}",
IsAI = seat.IsAi
};
if (!seat.IsAi)
{
realPids.Add(seat.PlayerId);
_queuedPids.Remove(seat.PlayerId);
}
}
var sink = new RoomOutputSink(roomId, realPids, _sessions);
var cfg = new RoomConfig
{
GameId = match.GameId,
Version = match.Version,
Seed = (ulong)_roomSeq,
PlayerCount = seats.Count
};
Room room;
try
{
room = _host.CreateRoom(roomId, match.GameId, match.Version, cfg, playerInfos, sink);
}
catch (Exception ex)
{
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
// Notify real players of error
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
foreach (int rpid in realPids)
SendFramework(rpid, FrameworkOpcode.Error, errPayload);
return;
}
// Build MatchFound message with all players info
var allPlayers = new List<PlayerInfo>(playerInfos);
// For each real player: set session.RoomId + send MatchFound
foreach (int rpid in realPids)
{
var s = _sessions.Get(rpid);
if (s != null) s.RoomId = roomId;
var found = new MatchFoundMsg
{
RoomId = roomId,
Version = match.Version,
SelfPlayerId = rpid
};
foreach (var pi in allPlayers) found.Players.Add(pi);
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
}
_roomPlayers[roomId] = realPids;
if (!room.IsActive) OnRoomEnded(roomId, room.TrustedEndResult); // 极端:Start 即结束
}
private void OnRoomEnded(string roomId, RoomEndResult result)
{
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
_roomPlayers.Remove(roomId);
var msg = new RoomEndMsg
{
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
};
byte[] payload = msg.Encode();
for (int i = 0; i < players.Count; i++)
{
int pid = players[i];
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
var s = _sessions.Get(pid);
if (s != null) s.RoomId = null;
}
}
private void SendFramework(int pid, FrameworkOpcode op, byte[] payload)
{
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace XWorld.Server.Gateway
{
public sealed class Session
{
public int PlayerId;
public IClientConnection Connection; // 当前连接;断线中仍保留会话但 Connected=false
public bool Connected;
public string RoomId; // 所在房间,可空
public long DisconnectedAtTick; // 断线时的逻辑 tick,用于窗口过期
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
// pid -> Session。只在 actor 线程被改,故无锁。
public sealed class SessionManager
{
private readonly Dictionary<int, Session> _byPid = new Dictionary<int, Session>();
public Session OnConnect(int pid, IClientConnection conn)
{
if (_byPid.TryGetValue(pid, out var s))
{
s.Connection = conn; // 重连:重绑新连接,保留 RoomId
s.Connected = true;
return s;
}
s = new Session { PlayerId = pid, Connection = conn, Connected = true };
_byPid[pid] = s;
return s;
}
public void OnDisconnect(int pid, IClientConnection conn, long currentTick)
{
// 仅当断的是"当前连接"才标离线,避免旧连接的迟到断线清掉已重连的新连接
if (_byPid.TryGetValue(pid, out var s) && ReferenceEquals(s.Connection, conn))
{
s.Connected = false;
s.DisconnectedAtTick = currentTick;
}
}
public IClientConnection GetConnection(int pid)
=> _byPid.TryGetValue(pid, out var s) && s.Connected ? s.Connection : null;
public bool IsConnected(int pid) => _byPid.TryGetValue(pid, out var s) && s.Connected;
public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;
/// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick &gt; windowTicks)。返回被移除的会话;无则空数组。</summary>
public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
{
List<Session> removed = null;
foreach (var kv in _byPid)
{
var s = kv.Value;
if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks)
(removed ??= new List<Session>()).Add(s);
}
if (removed != null)
foreach (var s in removed) _byPid.Remove(s.PlayerId);
return removed ?? (IReadOnlyList<Session>)Array.Empty<Session>();
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
namespace XWorld.Server.Gateway
{
// 独立内网 CDNKestrel 静态文件服务,HTTP 根 = root(发布产物 client/ 目录)。
// 仿 GameServerHost 的 Start/Stop 形态;缺失文件返回 404,无目录浏览。
public sealed class StaticFileServer
{
private readonly string _root;
private readonly int _port;
private readonly bool _bindAll;
private WebApplication _app;
public string HttpBaseUrl { get; private set; } // 例如 http://127.0.0.1:15081
public StaticFileServer(string root, int port = 15081, bool bindAllInterfaces = false)
{
if (string.IsNullOrWhiteSpace(root)) throw new ArgumentException("root 不能为空", nameof(root));
_root = Path.GetFullPath(root);
_port = port;
_bindAll = bindAllInterfaces;
}
public async Task StartAsync()
{
Directory.CreateDirectory(_root); // 根目录不存在则建空目录(GET 全 404,而非启动崩溃)
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o =>
o.Listen(_bindAll ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback, _port)); // 0 = 临时端口
var app = builder.Build();
var provider = new PhysicalFileProvider(_root);
var opts = new StaticFileOptions
{
FileProvider = provider,
ServeUnknownFileTypes = true, // .bytes/.unity3d/.sig 等无标准 MIME 也要发
DefaultContentType = "application/octet-stream",
};
app.UseStaticFiles(opts); // 仅静态文件,不开目录浏览;未命中落到下面的 404
await app.StartAsync();
_app = app;
string http = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses.FirstOrDefault()
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
HttpBaseUrl = http;
}
public async Task StopAsync()
{
if (_app == null) return;
await _app.StopAsync();
await _app.DisposeAsync();
_app = null;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using System;
using System.IO;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Cli
{
// 瘦壳:参数解析 → PublishLayout(全部逻辑在 PublishTool 库,有 xUnit 覆盖)
internal static class Program
{
// 退出码:0 成功;1 参数错误;2 发布失败
private static int Main(string[] args)
{
CliOptions o;
try { o = CliOptions.Parse(args); }
catch (ArgumentException e) { Console.Error.WriteLine(e.Message); return 1; }
try
{
GameSpec spec = GameSpecJson.ParseFile(o.SpecPath);
string key = o.KeyPath == null ? null : File.ReadAllText(o.KeyPath);
var layout = new PublishLayout();
if (o.Mode == "server") layout.PublishServer(o.Src, o.Out, spec, key);
else layout.PublishClient(o.Src, o.Out, spec, key);
Console.WriteLine($"published {o.Mode} {spec.GameId}/{spec.Version} -> {o.Out}" +
(key == null ? " [WARN] 未签名(files.txt.sig 为空)" : " [signed]"));
return 0;
}
catch (Exception e)
{
Console.Error.WriteLine("publish failed: " + e.Message);
return 2;
}
}
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyName>XWorld.PublishTool.Cli</AssemblyName>
<RootNamespace>XWorld.PublishTool.Cli</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,34 @@
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class CliOptionsTests
{
[Fact]
public void Parse_AllArgs_Ok()
{
var o = CliOptions.Parse(new[]
{ "--mode", "client", "--src", "S", "--out", "O", "--spec", "spec.json", "--key", "k.pem" });
Assert.Equal("client", o.Mode);
Assert.Equal("S", o.Src);
Assert.Equal("O", o.Out);
Assert.Equal("spec.json", o.SpecPath);
Assert.Equal("k.pem", o.KeyPath);
}
[Fact]
public void Parse_KeyOptional()
{
var o = CliOptions.Parse(new[] { "--mode", "server", "--src", "S", "--out", "O", "--spec", "sp" });
Assert.Null(o.KeyPath);
}
[Theory]
[InlineData(new object[] { new[] { "--mode", "bogus", "--src", "S", "--out", "O", "--spec", "sp" } })]
[InlineData(new object[] { new[] { "--src", "S", "--out", "O", "--spec", "sp" } })]
[InlineData(new object[] { new[] { "--mode", "client", "--out", "O", "--spec", "sp" } })]
public void Parse_Invalid_Throws(string[] args)
=> Assert.Throws<System.ArgumentException>(() => CliOptions.Parse(args));
}
}
@@ -0,0 +1,41 @@
using Xunit;
using XWorld.PublishTool;
using XGame.MiniGame;
namespace XWorld.PublishTool.Tests
{
public class ClientServerDigestConsistencyTests
{
[Fact]
public void ClientDigest_Matches_ServerDigest_ForSameFilesTxt()
{
// 服务端发布侧:构建清单、渲染 files.txt、算 digest(被签名的就是这个 digest)
var server = new FilesManifest();
server.Add("RPS.Core.dll.bytes", "aaa111bbb222ccc333ddd444eee555ff", 100);
server.Add("RPS.Client.dll.bytes", "11112222333344445555666677778888", 200);
server.Add("ui_rps.unity3d", "ffffeeeeddddccccbbbbaaaa99998888", 4096);
string filesTxt = server.Render();
string serverDigest = server.Digest();
// 客户端下载侧:拿到同一份 files.txt → LoadFilesList → Digest(验签时重算的就是这个)
var client = new MiniGameManifest();
client.LoadFilesList(filesTxt);
string clientDigest = client.Digest();
Assert.Equal(serverDigest, clientDigest); // 两端逐字节一致 → 签名可验
Assert.NotEqual("", clientDigest);
}
[Fact]
public void ClientDigest_IsOrderIndependent_LikeServer()
{
// 不同插入顺序,digest 应相同(两端都按 name Ordinal 排序)
var s1 = new FilesManifest(); s1.Add("b", "22", 2); s1.Add("a", "11", 1);
var s2 = new FilesManifest(); s2.Add("a", "11", 1); s2.Add("b", "22", 2);
var c1 = new MiniGameManifest(); c1.LoadFilesList(s1.Render());
var c2 = new MiniGameManifest(); c2.LoadFilesList(s2.Render());
Assert.Equal(c1.Digest(), c2.Digest());
Assert.Equal(s1.Digest(), c1.Digest());
}
}
}
@@ -0,0 +1,38 @@
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class FilesManifestTests
{
[Fact]
public void Render_And_Parse_RoundTrips()
{
var m = new FilesManifest();
m.Add("RPS.Core.dll.bytes", "abc123", 100);
m.Add("ui_rps_abc.unity3d", "def456", 2048);
string text = m.Render();
var back = FilesManifest.Parse(text);
Assert.Equal(2, back.Entries.Count);
Assert.Equal("abc123", back.Entries["RPS.Core.dll.bytes"].Md5);
Assert.Equal(2048, back.Entries["ui_rps_abc.unity3d"].Size);
}
[Fact]
public void Render_LineFormat_MatchesDllfilesConvention()
{
var m = new FilesManifest();
m.Add("a.dll", "m5", 7);
Assert.Equal("{'a.dll','m5',7},\n", m.Render());
}
[Fact]
public void Digest_IsStable_RegardlessOfInsertOrder()
{
var a = new FilesManifest(); a.Add("x", "1", 1); a.Add("y", "2", 2);
var b = new FilesManifest(); b.Add("y", "2", 2); b.Add("x", "1", 1);
Assert.Equal(a.Digest(), b.Digest()); // 按 name 排序后摘要,顺序无关
}
}
}
@@ -0,0 +1,44 @@
using System.Text.Json;
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class GameJsonWriterTests
{
private static readonly GameSpec Spec = new GameSpec
{
GameId = "rps", Version = 2, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
CodeAb = "code.unity3d",
Assets = new System.Collections.Generic.List<string> { "res.unity3d" }
};
[Fact]
public void Server_Json_HasRequiredFields()
{
using var doc = JsonDocument.Parse(GameJsonWriter.WriteServer(Spec));
var r = doc.RootElement;
Assert.Equal("rps", r.GetProperty("gameId").GetString());
Assert.Equal(2, r.GetProperty("version").GetInt32());
Assert.Equal("RPS.Server.dll", r.GetProperty("serverAssembly").GetString());
Assert.Equal("RPS.Server.RpsServerRoom", r.GetProperty("serverEntryType").GetString());
Assert.Equal(2, r.GetProperty("playerCount").GetInt32());
Assert.Equal(10, r.GetProperty("tickRateHz").GetInt32());
}
[Fact]
public void Client_Json_HasRequiredFields()
{
using var doc = JsonDocument.Parse(GameJsonWriter.WriteClient(Spec));
var r = doc.RootElement;
Assert.Equal("rps", r.GetProperty("gameId").GetString());
Assert.Equal("RPS.Client.RpsGameClient", r.GetProperty("clientEntryType").GetString());
Assert.Equal("RPS.Core.dll", r.GetProperty("coreDll").GetString());
Assert.Equal("RPS.Client.dll", r.GetProperty("clientDll").GetString());
Assert.Equal("code.unity3d", r.GetProperty("codeAb").GetString());
Assert.Equal("res.unity3d", r.GetProperty("assets")[0].GetString());
}
}
}
@@ -0,0 +1,44 @@
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class GameSpecJsonTests
{
[Fact]
public void Parse_LowercaseKeys_FillsAllFields()
{
// Editor 侧 JsonUtility 输出小写字段名;解析须大小写不敏感、含 fields
string json = @"{
""gameId"": ""rps"", ""version"": 3, ""minFrameworkVersion"": 1,
""playerCount"": 2, ""tickRateHz"": 10,
""serverAssembly"": ""RPS.Server.dll"", ""serverEntryType"": ""RPS.Server.RpsServerRoom"",
""coreDll"": ""RPS.Core.dll"", ""clientDll"": ""RPS.Client.dll"",
""clientEntryType"": ""RPS.Client.RpsGameClient"",
""codeAb"": ""code.unity3d"", ""assets"": [""res.unity3d""]
}";
var s = GameSpecJson.Parse(json);
Assert.Equal("rps", s.GameId);
Assert.Equal(3, s.Version);
Assert.Equal(1, s.MinFrameworkVersion);
Assert.Equal(2, s.PlayerCount);
Assert.Equal(10, s.TickRateHz);
Assert.Equal("RPS.Server.dll", s.ServerAssembly);
Assert.Equal("RPS.Server.RpsServerRoom", s.ServerEntryType);
Assert.Equal("RPS.Core.dll", s.CoreDll);
Assert.Equal("RPS.Client.dll", s.ClientDll);
Assert.Equal("RPS.Client.RpsGameClient", s.ClientEntryType);
Assert.Equal("code.unity3d", s.CodeAb);
Assert.Single(s.Assets);
Assert.Equal("res.unity3d", s.Assets[0]);
}
[Fact]
public void Parse_MissingAssets_YieldsEmptyList_NotNull()
{
var s = GameSpecJson.Parse(@"{""gameId"":""x"",""version"":1,""codeAb"":""code.unity3d""}");
Assert.NotNull(s.Assets);
Assert.Empty(s.Assets);
}
}
}
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class ManifestSignerTests
{
[Fact]
public void Sign_Then_Verify_Succeeds()
{
using var rsa = RSA.Create(2048);
string priv = rsa.ExportRSAPrivateKeyPem();
string pub = rsa.ExportRSAPublicKeyPem();
byte[] sig = ManifestSigner.Sign("digest-content", priv);
Assert.True(ManifestSigner.Verify("digest-content", sig, pub));
}
[Fact]
public void Verify_Fails_OnTamperedContent()
{
using var rsa = RSA.Create(2048);
byte[] sig = ManifestSigner.Sign("original", rsa.ExportRSAPrivateKeyPem());
Assert.False(ManifestSigner.Verify("tampered", sig, rsa.ExportRSAPublicKeyPem()));
}
[Fact]
public void Verify_Fails_OnWrongKey()
{
using var signer = RSA.Create(2048);
using var other = RSA.Create(2048);
byte[] sig = ManifestSigner.Sign("c", signer.ExportRSAPrivateKeyPem());
Assert.False(ManifestSigner.Verify("c", sig, other.ExportRSAPublicKeyPem()));
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class Md5UtilTests
{
[Fact]
public void Hex_OfKnownBytes_IsStableLowercase32()
{
// MD5("abc") = 900150983cd24fb0d6963f7d28e17f72
var hex = Md5Util.OfBytes(System.Text.Encoding.ASCII.GetBytes("abc"));
Assert.Equal("900150983cd24fb0d6963f7d28e17f72", hex);
}
[Fact]
public void Empty_IsKnownMd5()
{
Assert.Equal("d41d8cd98f00b204e9800998ecf8427e", Md5Util.OfBytes(new byte[0]));
}
}
}
@@ -0,0 +1,60 @@
using System.IO;
using Xunit;
namespace XWorld.PublishTool.Tests
{
public class MiniGameHostUiTests
{
// Unity 2022.2+ 移除了内置 Arial.ttfGetBuiltinResource("Arial.ttf") 会抛
// ArgumentException,导致结算弹框构建中断(只剩遮罩无文字无按钮)。
[Fact]
public void MiniGameHost_UsesLegacyRuntimeFont_NotRemovedArial()
{
string source = File.ReadAllText(Path.Combine(
RepoRoot(),
"Client",
"Assets",
"Script",
"xmain",
"MiniGame",
"MiniGameHost.cs"));
Assert.DoesNotContain("Arial.ttf", source);
Assert.Contains("LegacyRuntime.ttf", source);
}
// 结算弹框须走标准 UGUI 预设(JSON→Prefab 管线产物)+ 异步加载,代码构建仅作回退。
[Fact]
public void MiniGameHost_LoadsResultDialogPrefab_Async()
{
string source = File.ReadAllText(Path.Combine(
RepoRoot(),
"Client",
"Assets",
"Script",
"xmain",
"MiniGame",
"MiniGameHost.cs"));
Assert.Contains("UI_MiniGameResult.prefab", source);
Assert.Contains("coLoadRes", source); // 项目规则:资源一律异步加载
// 预设的 JSON 描述文件必须存在(Doc/UIPrefabCreater 管线输入)
Assert.True(File.Exists(Path.Combine(
RepoRoot(), "Doc", "UIPrefabCreater", "UI_MiniGameResult.json")));
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "Client", "Assets", "MiniGames", "RockPaperScissors", "publish.json")))
return dir.FullName;
dir = dir.Parent;
}
throw new DirectoryNotFoundException("Could not locate repo root.");
}
}
}
@@ -0,0 +1,95 @@
using System.IO;
using System.Security.Cryptography;
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class PublishLayoutTests
{
private static GameSpec MakeSpec() => new GameSpec
{
GameId = "rps", Version = 1, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
CodeAb = "code.unity3d",
Assets = new System.Collections.Generic.List<string> { "res.unity3d" },
};
private static string TempDir() =>
Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pub_" + System.Guid.NewGuid().ToString("N"))).FullName;
[Fact]
public void PublishServer_ProducesDlls_GameJson_Manifest_Signature()
{
string src = TempDir(); string outDir = Path.Combine(TempDir(), "s");
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1, 2, 3 });
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 4, 5 });
using var rsa = RSA.Create(2048);
new PublishLayout().PublishServer(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Server.dll")));
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Core.dll")));
Assert.True(File.Exists(Path.Combine(outDir, "game.json")));
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
Assert.True(fm.Entries.ContainsKey("RPS.Server.dll"));
Assert.True(fm.Entries.ContainsKey("RPS.Core.dll"));
Assert.True(fm.Entries.ContainsKey("game.json"));
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
}
[Fact]
public void PublishClient_Md5NamedAbs_CodeAbInManifest_NoLooseBytes()
{
string src = TempDir(); string outDir = Path.Combine(TempDir(), "c");
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 6, 7, 8 });
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 9 });
using var rsa = RSA.Create(2048);
new PublishLayout().PublishClient(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
// files.txt 登记逻辑名;落盘 md5 命名(与 MiniGameDownloader.Md5Name 一致)
Assert.True(fm.Entries.ContainsKey("code.unity3d"));
Assert.True(fm.Entries.ContainsKey("res.unity3d"));
string codeMd5 = Md5Util.OfFile(Path.Combine(src, "code.unity3d"));
string resMd5 = Md5Util.OfFile(Path.Combine(src, "res.unity3d"));
Assert.True(File.Exists(Path.Combine(outDir, $"code_{codeMd5}.unity3d")));
Assert.True(File.Exists(Path.Combine(outDir, $"res_{resMd5}.unity3d")));
// 统一 AB:不再有散 .bytes 文件
Assert.Empty(Directory.GetFiles(outDir, "*.bytes"));
// game.json 带 codeAb
Assert.Contains("\"codeAb\": \"code.unity3d\"", File.ReadAllText(Path.Combine(outDir, "game.json")));
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
}
[Fact]
public void NullKey_WritesEmptySig_BothTrees()
{
string src = TempDir();
string outS = Path.Combine(TempDir(), "s"); string outC = Path.Combine(TempDir(), "c");
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1 });
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 2 });
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 3 });
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 4 });
var layout = new PublishLayout();
layout.PublishServer(src, outS, MakeSpec(), null);
layout.PublishClient(src, outC, MakeSpec(), null);
Assert.Equal(0, new FileInfo(Path.Combine(outS, "files.txt.sig")).Length);
Assert.Equal(0, new FileInfo(Path.Combine(outC, "files.txt.sig")).Length);
}
[Fact]
public void PublishClient_EmptyCodeAb_Throws()
{
var spec = MakeSpec(); spec.CodeAb = null;
Assert.Throws<System.InvalidOperationException>(
() => new PublishLayout().PublishClient(TempDir(), Path.Combine(TempDir(), "c"), spec, null));
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</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="..\PublishTool\PublishTool.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Client\Assets\Script\xmain\MiniGame\MiniGameManifest.cs">
<Link>Linked\MiniGameManifest.cs</Link>
</Compile>
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
using System.IO;
using Xunit;
namespace XWorld.PublishTool.Tests
{
public class RpsClientUiTests
{
[Fact]
public void RpsClient_UsesPackagedUgUiPrefab_InsteadOfImgui()
{
string source = File.ReadAllText(Path.Combine(
RepoRoot(),
"Client",
"Assets",
"MiniGames",
"RockPaperScissors",
"scripts~",
"Client",
"RpsGameClient.cs"));
Assert.Contains("UI_RockPaperScissors.prefab", source);
Assert.Contains("_ctx.Assets.Load", source);
Assert.DoesNotContain("OnGUI", source);
Assert.DoesNotContain("GUILayout.", source);
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "Client", "Assets", "MiniGames", "RockPaperScissors", "publish.json")))
return dir.FullName;
dir = dir.Parent;
}
throw new DirectoryNotFoundException("Could not locate repo root.");
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
namespace XWorld.PublishTool
{
// PublishTool.Cli 的参数:--mode client|server --src <dir> --out <dir> --spec <json> [--key <pem>]
public sealed class CliOptions
{
public string Mode; // "client" | "server"
public string Src; // 构建产物目录
public string Out; // 落位目录(版本目录本体)
public string SpecPath; // resolved-spec.json
public string KeyPath; // 私钥 pem,可空=不签名
public const string Usage =
"用法: PublishTool.Cli --mode client|server --src <dir> --out <dir> --spec <resolved-spec.json> [--key <private.pem>]";
public static CliOptions Parse(string[] args)
{
var o = new CliOptions();
for (int i = 0; i < args.Length; i += 2)
{
if (i + 1 >= args.Length) throw new ArgumentException($"缺少 {args[i]} 的值\n{Usage}");
string v = args[i + 1];
switch (args[i])
{
case "--mode": o.Mode = v; break;
case "--src": o.Src = v; break;
case "--out": o.Out = v; break;
case "--spec": o.SpecPath = v; break;
case "--key": o.KeyPath = v; break;
default: throw new ArgumentException($"未知参数 {args[i]}\n{Usage}");
}
}
if (o.Mode != "client" && o.Mode != "server")
throw new ArgumentException($"--mode 必须是 client 或 server\n{Usage}");
if (string.IsNullOrEmpty(o.Src) || string.IsNullOrEmpty(o.Out) || string.IsNullOrEmpty(o.SpecPath))
throw new ArgumentException($"--src/--out/--spec 均必填\n{Usage}");
return o;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace XWorld.PublishTool
{
public sealed class FilesManifest
{
public sealed class Entry { public string Md5; public int Size; }
public Dictionary<string, Entry> Entries { get; } = new Dictionary<string, Entry>();
public void Add(string name, string md5, int size) => Entries[name] = new Entry { Md5 = md5, Size = size };
// 行格式沿用 dllfiles{'name','md5',size},\n(按 name 排序,保证确定性)
public string Render()
{
var sb = new StringBuilder();
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
sb.Append("{'").Append(kv.Key).Append("','").Append(kv.Value.Md5).Append("',").Append(kv.Value.Size).Append("},\n");
return sb.ToString();
}
public static FilesManifest Parse(string text)
{
var m = new FilesManifest();
if (string.IsNullOrEmpty(text)) return m;
foreach (var raw in text.Split('\n'))
{
string line = raw.Trim();
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
if (lb < 0 || rb <= lb) continue;
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
string[] p = inner.Split(',');
if (p.Length < 3) continue;
int.TryParse(p[2].Trim(), out int size);
m.Add(p[0].Trim(), p[1].Trim(), size);
}
return m;
}
// 稳定摘要:排序后的 name+md5 串(用于签名)
public string Digest()
{
var sb = new StringBuilder();
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
sb.Append(kv.Key).Append('=').Append(kv.Value.Md5).Append(';');
return sb.ToString();
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Text.Json;
namespace XWorld.PublishTool
{
public sealed class GameSpec
{
public string GameId; public int Version; public int MinFrameworkVersion;
public int PlayerCount; public int TickRateHz;
public string ServerAssembly; public string ServerEntryType;
public string CoreDll; public string ClientDll; public string ClientEntryType;
public string CodeAb; // 代码 AB 文件名(统一 ABDLL .bytes 打进它)
public List<string> Assets = new List<string>(); // 资源 AB 完整文件名列表
}
public static class GameJsonWriter
{
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions { WriteIndented = true };
public static string WriteServer(GameSpec s) => JsonSerializer.Serialize(new
{
gameId = s.GameId, version = s.Version, serverAssembly = s.ServerAssembly,
serverEntryType = s.ServerEntryType, minFrameworkVersion = s.MinFrameworkVersion,
playerCount = s.PlayerCount, tickRateHz = s.TickRateHz
}, Opts);
public static string WriteClient(GameSpec s) => JsonSerializer.Serialize(new
{
gameId = s.GameId, version = s.Version, clientEntryType = s.ClientEntryType,
coreDll = s.CoreDll, clientDll = s.ClientDll, minFrameworkVersion = s.MinFrameworkVersion,
codeAb = s.CodeAb, assets = s.Assets
}, Opts);
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.IO;
using System.Text.Json;
namespace XWorld.PublishTool
{
// resolved-spec.jsonEditor 侧 JsonUtility 产出,字段名小写)→ GameSpec。
// GameSpec 用 public 字段,故必须 IncludeFields;键名大小写不敏感。
public static class GameSpecJson
{
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions
{
IncludeFields = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static GameSpec Parse(string json)
{
var s = JsonSerializer.Deserialize<GameSpec>(json, Opts);
if (s.Assets == null) s.Assets = new System.Collections.Generic.List<string>();
return s;
}
public static GameSpec ParseFile(string path) => Parse(File.ReadAllText(path));
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Security.Cryptography;
using System.Text;
namespace XWorld.PublishTool
{
public static class ManifestSigner
{
public static byte[] Sign(string content, string privateKeyPem)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
return rsa.SignData(Encoding.UTF8.GetBytes(content), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
public static bool Verify(string content, byte[] signature, string publicKeyPem)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(Encoding.UTF8.GetBytes(content), signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace XWorld.PublishTool
{
public static class Md5Util
{
public static string OfBytes(byte[] data)
{
using (var md5 = MD5.Create())
{
byte[] h = md5.ComputeHash(data ?? Array.Empty<byte>());
var sb = new StringBuilder(h.Length * 2);
foreach (var b in h) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
public static string OfFile(string path) => OfBytes(File.ReadAllBytes(path));
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
using System.IO;
namespace XWorld.PublishTool
{
// 把构建产物落成发布目录 + 清单 + (可选)签名。
// privateKeyPem 传 null = 不签名:files.txt.sig 落空文件(两端验签均为"未配置则跳过")。
public sealed class PublishLayout
{
// 服务器树:outDir 即 <gamesRoot>/<id>/<ver>/ 的内容(普通文件名,不做 md5 命名)
public void PublishServer(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
{
Directory.CreateDirectory(outDir);
CopyTo(srcDir, spec.ServerAssembly, outDir);
CopyTo(srcDir, spec.CoreDll, outDir);
// game.json 先写,确保其 md5 纳入清单
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteServer(spec));
var fm = new FilesManifest();
foreach (var name in new[] { spec.ServerAssembly, spec.CoreDll, "game.json" })
{
byte[] b = File.ReadAllBytes(Path.Combine(outDir, name));
fm.Add(name, Md5Util.OfBytes(b), b.Length);
}
WriteManifestAndSig(outDir, fm, privateKeyPem);
}
// 客户端树:outDir 即 CDN/minigame/<id>/<ver>/<platform>/ 的内容。
// spec.CodeAb / spec.Assets 均为完整 AB 文件名(code.unity3d / res.unity3d),统一 AB:无散 .bytes。
public void PublishClient(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
{
if (string.IsNullOrEmpty(spec.CodeAb))
throw new InvalidOperationException("spec.CodeAb 为空:客户端发布需要代码 AB(统一 AB 格式)");
Directory.CreateDirectory(outDir);
var fm = new FilesManifest();
CopyMd5Named(srcDir, spec.CodeAb, outDir, fm);
foreach (var ab in spec.Assets) CopyMd5Named(srcDir, ab, outDir, fm);
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteClient(spec));
WriteManifestAndSig(outDir, fm, privateKeyPem);
}
private static void WriteManifestAndSig(string outDir, FilesManifest fm, string privateKeyPem)
{
File.WriteAllText(Path.Combine(outDir, "files.txt"), fm.Render());
byte[] sig = privateKeyPem == null
? Array.Empty<byte>()
: ManifestSigner.Sign(fm.Digest(), privateKeyPem);
File.WriteAllBytes(Path.Combine(outDir, "files.txt.sig"), sig);
}
private static void CopyTo(string srcDir, string name, string dstDir)
=> File.Copy(Path.Combine(srcDir, name), Path.Combine(dstDir, name), true);
// 落盘 md5 命名(name_md5.ext,与 MiniGameDownloader.Md5Name 推导一致),files.txt 登记逻辑名
private static void CopyMd5Named(string srcDir, string name, string dstDir, FilesManifest fm)
{
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, name));
string md5 = Md5Util.OfBytes(data);
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(name, md5)), data);
fm.Add(name, md5, data.Length);
}
// name.ext -> name_md5.ext(无扩展名则 name_md5
private static string Md5Name(string filename, string md5)
{
int pos = filename.LastIndexOf('.');
return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}"
: $"{filename}_{md5}";
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,50 @@
using System;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class AlcUnloadTests
{
private sealed class NullOutput : IRoomOutput
{
public void Broadcast(NetMessage m) { }
public void SendTo(int playerId, NetMessage m) { }
}
// 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
[MethodImpl(MethodImplOptions.NoInlining)]
private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
{
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
IGameServerRoom room = module.CreateRoom();
room.OnRoomStart(
new[]
{
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
},
new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
new InMemoryStorage(), new NullOutput(), _ => { }));
room.OnTick(1f); // dt=1 < 5s,仍在 Choosing,仅广播一次快照
var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
room = null;
return (module.Unload(), asmWeak);
}
[Fact]
public void GameModule_Unload_ReclaimsMemory()
{
var (alcWeak, asmWeak) = LoadRunAndUnload();
for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
}
}
}
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class FrameworkVersionGateTests
{
private static readonly IReadOnlyList<PlayerInfo> Players = new[]
{
new PlayerInfo { PlayerId = 1, Name = "Human", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
};
[Fact]
public void Load_GameWithinFrameworkVersion_Loads()
{
// RPS game.json has minFrameworkVersion=1 == FrameworkInfo.Version (1 > 1 is false → loads)
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
Assert.Equal("rps", module.GameId);
Assert.Equal(1, module.Version);
IGameServerRoom room = module.CreateRoom();
Assert.NotNull(room);
}
[Fact]
public void Load_GameRequiresNewerFramework_Rejected()
{
string tempRoot = Path.Combine(Path.GetTempPath(), "FrameworkGateTest_" + Guid.NewGuid().ToString("N"));
string tempModuleDir = Path.Combine(tempRoot, "rps", "1");
Directory.CreateDirectory(tempModuleDir);
try
{
// Copy RPS DLLs and game.json from the real TestGames fixture
string srcDir = Path.Combine(TestGames.Root, "rps", "1");
foreach (var file in Directory.GetFiles(srcDir))
File.Copy(file, Path.Combine(tempModuleDir, Path.GetFileName(file)));
// Rewrite game.json with minFrameworkVersion > current
string gameJsonPath = Path.Combine(tempModuleDir, "game.json");
string original = File.ReadAllText(gameJsonPath);
string patched = original.Replace(
$"\"minFrameworkVersion\": {FrameworkInfo.Version}",
$"\"minFrameworkVersion\": {FrameworkInfo.Version + 1}");
// Fallback: replace numeric value directly if exact string didn't match
if (patched == original)
patched = System.Text.RegularExpressions.Regex.Replace(
original,
@"""minFrameworkVersion""\s*:\s*\d+",
$"\"minFrameworkVersion\": {FrameworkInfo.Version + 1}");
File.WriteAllText(gameJsonPath, patched);
var ex = Assert.ThrowsAny<Exception>(
() => new GameModuleLoader().Load(tempRoot, "rps", 1));
Assert.IsType<InvalidOperationException>(ex);
}
finally
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
}
@@ -0,0 +1,50 @@
using System.IO;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameManifestTests
{
private const string Valid = @"{
""gameId"": ""rps"",
""version"": 2,
""serverAssembly"": ""RPS.Server.dll"",
""serverEntryType"": ""RPS.Server.RpsServerRoom"",
""minFrameworkVersion"": 1,
""playerCount"": 2,
""tickRateHz"": 10
}";
[Fact]
public void Parse_Valid_ReadsAllFields()
{
var m = GameManifest.Parse(Valid);
Assert.Equal("rps", m.GameId);
Assert.Equal(2, m.Version);
Assert.Equal("RPS.Server.dll", m.ServerAssembly);
Assert.Equal("RPS.Server.RpsServerRoom", m.ServerEntryType);
Assert.Equal(1, m.MinFrameworkVersion);
Assert.Equal(2, m.PlayerCount);
Assert.Equal(10, m.TickRateHz);
}
[Fact]
public void Parse_CaseInsensitive_FieldNames()
{
var m = GameManifest.Parse(@"{""GameId"":""x"",""Version"":1,""ServerAssembly"":""X.dll"",""ServerEntryType"":""X.Y""}");
Assert.Equal("x", m.GameId);
Assert.Equal(1, m.Version);
}
[Theory]
[InlineData(@"{""version"":1,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // 缺 gameId
[InlineData(@"{""gameId"":""x"",""version"":1,""serverAssembly"":""a.dll""}")] // 缺 entryType
[InlineData(@"{""gameId"":""x"",""version"":0,""serverEntryType"":""a"",""serverAssembly"":""a.dll""}")] // version 非正
[InlineData(@"{""gameId"":""x"",""version"":1,""serverEntryType"":""a""}")] // 缺 serverAssembly
public void Parse_Invalid_Throws(string json)
{
Assert.Throws<InvalidDataException>(() => GameManifest.Parse(json));
}
}
}
@@ -0,0 +1,16 @@
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleLoaderTests
{
// ALC 加载 + 跨界类型隔离 + 广播已由 RpsModuleLoadTests 覆盖;此处只保留缺版本的失败路径。
[Fact]
public void Load_MissingVersion_Throws()
{
Assert.ThrowsAny<System.Exception>(
() => new GameModuleLoader().Load(TestGames.Root, "rps", 99));
}
}
}
@@ -0,0 +1,64 @@
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class GameModuleRegistryTests
{
private static GameModuleRegistry NewRegistry()
=> new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);
[Fact]
public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
{
var reg = NewRegistry();
var a = reg.Acquire("rps", 1);
var b = reg.Acquire("rps", 1);
Assert.Same(a, b); // 复用同一模块
Assert.Equal(2, reg.RefCount("rps", 1));
}
[Fact]
public void Release_ToZero_WithoutSupersede_KeepsLoaded()
{
var reg = NewRegistry();
var m = reg.Acquire("rps", 1);
reg.Release(m);
Assert.Equal(0, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1)); // 未被淘汰则保留以复用
}
[Fact]
public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
{
var reg = NewRegistry();
var v1 = reg.Acquire("rps", 1); // v1 引用=1
var v2 = reg.Acquire("rps", 2); // 取 v2 → v1 被标记淘汰
Assert.True(reg.IsLoaded("rps", 1));
Assert.True(reg.IsLoaded("rps", 2));
reg.Release(v1); // v1 引用归零且被淘汰 → 卸载并移除
Assert.False(reg.IsLoaded("rps", 1));
Assert.True(reg.IsLoaded("rps", 2)); // v2 仍在
}
[Fact]
public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
{
var reg = NewRegistry();
var v1 = reg.Acquire("rps", 1);
Assert.ThrowsAny<System.Exception>(() => reg.Acquire("rps", 99)); // v99 不存在,加载抛出
reg.Release(v1); // v1 引用归零
Assert.True(reg.IsLoaded("rps", 1)); // 未被错误淘汰,仍保留
}
[Fact]
public void Release_MoreThanAcquired_Throws()
{
var reg = NewRegistry();
var m = reg.Acquire("rps", 1);
reg.Release(m);
Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
}
}
}
@@ -0,0 +1,182 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using Xunit;
using XWorld.PublishTool;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
/// <summary>
/// 设计§7:服务端加载时签名验证测试(TDD RED→GREEN)。
/// 验证 GameModuleLoader 在配置公钥时执行签名+md5 校验,未配置时跳过(向后兼容)。
/// </summary>
public class GameModuleSignatureTests : IDisposable
{
private readonly string _tempRoot;
private readonly string _gameDir; // {tempRoot}/rps/1/
public GameModuleSignatureTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "sig_test_" + Guid.NewGuid().ToString("N"));
_gameDir = Path.Combine(_tempRoot, "rps", "1");
Directory.CreateDirectory(_gameDir);
// 从已构建的 TestGames/rps/1 复制 3 个文件
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
File.Copy(Path.Combine(src, name), Path.Combine(_gameDir, name), overwrite: true);
}
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
// Windows 下 ALC 加载的 DLL 可能还被锁住;先做 GC 等待释放,再删除
for (int i = 0; i < 5; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
try { Directory.Delete(_tempRoot, true); }
catch (UnauthorizedAccessException) { /* Windows 文件锁:测试结束后 GC 会最终释放;不影响测试结果 */ }
}
}
// 生成签名 fixture 并写入 files.txt + files.txt.sig
private static (string pubPem, string privPem) GenerateKeyPair()
{
using var rsa = RSA.Create(2048);
return (rsa.ExportRSAPublicKeyPem(), rsa.ExportRSAPrivateKeyPem());
}
private void WriteSignature(string privPem)
{
var fm = new FilesManifest();
foreach (var name in new[] { "RPS.Server.dll", "RPS.Core.dll", "game.json" })
{
string p = Path.Combine(_gameDir, name);
byte[] b = File.ReadAllBytes(p);
fm.Add(name, Md5Util.OfBytes(b), b.Length);
}
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
File.WriteAllBytes(Path.Combine(_gameDir, "files.txt.sig"),
ManifestSigner.Sign(fm.Digest(), privPem));
}
[Fact]
public void Load_WithValidSignature_Succeeds()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
var module = new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1);
Assert.NotNull(module);
Assert.Equal("rps", module.GameId);
Assert.Equal(1, module.Version);
var room = module.CreateRoom();
Assert.NotNull(room);
// 卸载 ALC,释放 Windows 文件锁,以便 Dispose() 能清理临时目录
room = null;
module.Unload();
GC.Collect();
GC.WaitForPendingFinalizers();
}
[Fact]
public void Load_TamperedDll_Rejected()
{
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 篡改 RPS.Core.dll 的一个字节,md5 随之改变
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF; // 翻转首字节
File.WriteAllBytes(coreDll, bytes);
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("md5", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_WrongKey_Rejected()
{
var (_, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 用另一组密钥的公钥验证 → 签名不符
var (wrongPubPem, _) = GenerateKeyPair();
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(wrongPubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_MissingSignature_WhenKeyConfigured_Rejected()
{
var (pubPem, _) = GenerateKeyPair();
// 故意不写 files.txt.sig(签名文件缺失)
var ex = Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
Assert.Contains("签名", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_NoKeyConfigured_SkipsVerification()
{
// 不配置公钥,使用无签名的原始 TestGames/rps/1 目录
// 向后兼容:无公钥时跳过验证,正常加载
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
Assert.NotNull(module);
Assert.Equal("rps", module.GameId);
var room = module.CreateRoom();
Assert.NotNull(room);
}
[Fact]
public void Load_TamperedGameJson_Rejected()
{
// 构建签名 fixture(与其他测试相同方式)
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 篡改 game.json:追加一个空格,改变其 md5,使清单 md5 校验失败
File.AppendAllText(Path.Combine(_gameDir, "game.json"), " ");
Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
}
[Fact]
public void Load_TamperedManifestWithoutResign_Rejected()
{
// 构建签名 fixture
var (pubPem, privPem) = GenerateKeyPair();
WriteSignature(privPem);
// 攻击者篡改 RPS.Core.dll 一字节,并重算其 md5 写回 files.txt
// 但没有私钥无法重签 files.txt.sig → 验签时发现 Digest 与 sig 不符 → 拒绝
string coreDll = Path.Combine(_gameDir, "RPS.Core.dll");
byte[] bytes = File.ReadAllBytes(coreDll);
bytes[0] ^= 0xFF;
File.WriteAllBytes(coreDll, bytes);
// 重算被篡改 DLL 的 md5,重写 files.txtfiles.txt.sig 保持不变)
string newMd5 = Md5Util.OfFile(coreDll);
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(_gameDir, "files.txt")));
fm.Entries["RPS.Core.dll"].Md5 = newMd5;
File.WriteAllText(Path.Combine(_gameDir, "files.txt"), fm.Render());
Assert.ThrowsAny<Exception>(() =>
new GameModuleLoader(pubPem).Load(_tempRoot, "rps", 1));
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.IO;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
// 字节加载去锁:模块加载期间磁盘 DLL 可被覆盖/删除(同版本覆盖重发的前提)
public class ModuleFileLockTests
{
// 把共享只读夹具 rps/1 拷到独立临时 gamesRoot(不许改 TestGames.Root 原件)
private static string CopyRps1ToTempRoot()
{
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
string dst = Path.Combine(root, "rps", "1");
Directory.CreateDirectory(dst);
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var f in Directory.GetFiles(src))
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
return root;
}
[Fact]
public void LoadedModule_DoesNotLockDlls_OverwriteAndDeleteSucceed()
{
string root = CopyRps1ToTempRoot();
var module = new GameModuleLoader().Load(root, "rps", 1);
try
{
// 先构造一次房间:RpsServerRoom 字段初始化器 new RpsLogic() 触发 RPS.Core
// 首次解析、经 GameModuleAlc 字节加载进本 ALC —— 此后两个 DLL 的 IL 都在内存。
// (不先触发的话,"删 Core 后 CreateRoom 成功"会被测试工程对 RPS.Core 的
// ProjectReference 经默认上下文回退兜住,测不到被测代码。)
Assert.NotNull(module.CreateRoom());
string serverDll = Path.Combine(root, "rps", "1", "RPS.Server.dll");
string coreDll = Path.Combine(root, "rps", "1", "RPS.Core.dll");
// 加载期间覆盖写入 —— LoadFromAssemblyPath 会锁文件抛 IOException,字节加载应成功
byte[] original = File.ReadAllBytes(serverDll);
File.WriteAllBytes(serverDll, original);
// 加载期间删除(两个都删:IL 已在内存,源文件不再被需要也不被锁定)
File.Delete(coreDll);
File.Delete(serverDll);
Assert.False(File.Exists(coreDll));
Assert.False(File.Exists(serverDll));
// 模块本体仍可用
Assert.NotNull(module.CreateRoom());
}
finally
{
module.Unload();
try { Directory.Delete(root, true); } catch { }
}
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
// 同 (gameId,version) 磁盘内容变化(覆盖重发)→ Registry 自动失效重载;
// 旧房间打完旧代码(退役条目引用归零即卸载),新房间用新代码。
public class RegistryContentInvalidationTests : IDisposable
{
private string _root;
private string CopyRps1ToTempRoot()
{
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
string dst = Path.Combine(root, "rps", "1");
Directory.CreateDirectory(dst);
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var f in Directory.GetFiles(src))
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
_root = root;
return root;
}
public void Dispose()
{
if (_root != null)
{
try { Directory.Delete(_root, true); } catch { }
}
}
// 改变内容指纹(game.json 在指纹范围内;追加空白不影响 JSON 解析)
private static void TouchContent(string root)
{
string gameJson = Path.Combine(root, "rps", "1", "game.json");
File.AppendAllText(gameJson, " ");
}
[Fact]
public void SameContent_ReturnsCachedInstance()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
var b = reg.Acquire("rps", 1);
Assert.Same(a, b);
Assert.Equal(2, reg.RefCount("rps", 1));
}
[Fact]
public void ContentChange_AfterRefZero_ReloadsNewInstance()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
reg.Release(a); // 引用归零,未淘汰 → 保留缓存
TouchContent(root);
var b = reg.Acquire("rps", 1);
Assert.NotSame(a, b); // 内容变了 → 新模块实例
Assert.Equal(1, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1));
}
[Fact]
public void ContentChange_WhileOldActive_OldAndNewCoexist_ReleaseBothCorrect()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var oldM = reg.Acquire("rps", 1); // 旧房间在打
TouchContent(root);
var newM = reg.Acquire("rps", 1); // 新房间 → 新内容
Assert.NotSame(oldM, newM);
Assert.Equal(1, reg.RefCount("rps", 1)); // RefCount 只统计当前条目
reg.Release(oldM); // 旧房间结束:退役条目按实例识别释放,不抛
reg.Release(newM);
Assert.Equal(0, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1)); // 当前条目未被淘汰 → 保留复用
}
[Fact]
public void ContentChange_RetiredModule_UnloadsAndReclaims()
{
string root = CopyRps1ToTempRoot();
var weak = AcquireChangeReleaseOld(root);
for (int i = 0; i < 12 && weak.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Assert.False(weak.IsAlive, "退役模块未被回收 —— 存在泄漏引用");
}
// 独立方法确保局部强引用随返回消失(同 AlcUnloadTests 套路)
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference AcquireChangeReleaseOld(string root)
{
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var oldM = reg.Acquire("rps", 1);
TouchContent(root);
var newM = reg.Acquire("rps", 1); // oldM 退役
var weak = new WeakReference(oldM);
reg.Release(oldM); // 退役 + 归零 → 卸载
reg.Release(newM);
return weak;
}
[Fact]
public void MissingDir_ServesCachedEntry()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
string dir = Path.Combine(root, "rps", "1");
string away = dir + ".away";
Directory.Move(dir, away); // 模拟覆盖交换的瞬间窗口
try
{
var b = reg.Acquire("rps", 1); // 指纹算不出 → 用缓存兜底
Assert.Same(a, b);
}
finally { Directory.Move(away, dir); }
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class RoomCtxTests
{
private sealed class FakeOutput : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public List<(int, NetMessage)> Sends = new List<(int, NetMessage)>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) => Sends.Add((playerId, m));
}
[Fact]
public void Broadcast_And_SendTo_RouteToOutput()
{
var output = new FakeOutput();
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), output, _ => { });
ctx.Broadcast(new NetMessage(7, new byte[] { 1 }));
ctx.SendTo(42, new NetMessage(8, new byte[] { 2 }));
Assert.Single(output.Broadcasts);
Assert.Equal((ushort)7, output.Broadcasts[0].Opcode);
Assert.Single(output.Sends);
Assert.Equal(42, output.Sends[0].Item1);
}
[Fact]
public void EndRoom_InvokesCallback()
{
bool ended = false;
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), _ => ended = true);
ctx.EndRoom();
Assert.True(ended);
}
[Fact]
public void EndRoom_WithResult_PassesResultToCallback()
{
RoomEndResult seen = null;
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
var result = new RoomEndResult { WinnerPlayerId = 42, ResultBlob = new byte[] { 1 } };
ctx.EndRoom(result);
Assert.Same(result, seen);
}
[Fact]
public void EndRoom_Parameterless_PassesNull()
{
var seen = new RoomEndResult(); // 先放非 null,验证回调真的收到 null
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
ctx.EndRoom();
Assert.Null(seen);
}
[Fact]
public void ManualClock_Advances()
{
var clock = new ManualClock();
Assert.Equal(0f, clock.Now);
clock.Advance(1.5f);
clock.Advance(0.5f);
Assert.Equal(2f, clock.Now);
}
[Fact]
public void InMemoryStorage_SaveLoad_RoundTrips_AndMissingReturnsNull()
{
var s = new InMemoryStorage();
Assert.Null(s.Load("k"));
s.Save("k", new byte[] { 9 });
Assert.Equal(new byte[] { 9 }, s.Load("k"));
}
}
}
+110
View File
@@ -0,0 +1,110 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using RPS.Core;
namespace XWorld.Server.Host.Tests
{
public class RoomHostTests
{
private sealed class Capture : IRoomOutput
{
public List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage m) => Broadcasts.Add(m);
public void SendTo(int playerId, NetMessage m) { }
}
// 1 真人(seat 0) + 1 AI(seat 1)
private static readonly IReadOnlyList<PlayerInfo> Players = new[]
{
new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
};
private static RpsState LastState(Capture cap)
=> new RpsLogic().Decode(cap.Broadcasts[cap.Broadcasts.Count - 1].Payload);
private static RoomHost NewHost()
=> new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());
private static RoomConfig Cfg()
=> new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 };
[Fact]
public void CreateRoom_TickToEnd_ReleasesModule()
{
var host = NewHost();
var cap = new Capture();
var room = host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
Assert.Equal(1, host.ActiveRoomCount);
// 大 dt 推进:60s 对局在数个大 tick 内结束
for (int i = 0; i < 10 && host.ActiveRoomCount > 0; i++) host.TickAll(10f);
Assert.Equal(0, host.ActiveRoomCount);
Assert.False(room.IsActive);
Assert.Equal(RpsPhase.Finished, LastState(cap).Phase);
}
[Fact]
public void Deliver_RoutesChoice_AffectsState()
{
var host = NewHost();
var cap = new Capture();
host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
// seat 0(玩家 1)出 Rockopcode=1(ChoiceOpcode)payload=单字节 choice
var w = new PacketWriter();
w.WriteByte((byte)Choice.Rock);
host.DeliverTo("A", 1, new NetMessage(1, w.ToArray()));
host.TickAll(0.1f); // 小 dt,仍在 Choosing,广播快照
Assert.Equal(Choice.Rock, LastState(cap).Choices[0]);
}
[Fact]
public void CreateRoom_DuplicateRoomId_Throws()
{
var host = NewHost();
host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture());
Assert.Throws<System.InvalidOperationException>(
() => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
}
[Fact]
public void TickAll_ReturnsEndedRoomIds()
{
var host = NewHost();
host.CreateRoom("E", "rps", 1, Cfg(), Players, new Capture());
var first = host.TickAll(10f); // 第一大 tickTotalElapsed=10 < 60,不结束
Assert.Empty(first);
var ended = new List<EndedRoom>();
for (int i = 0; i < 10 && !ended.Exists(e => e.RoomId == "E"); i++)
ended.AddRange(host.TickAll(10f));
Assert.Contains(ended, e => e.RoomId == "E"); // 累计超过 60s 后该 tick 返回结束房间
}
[Fact]
public void TickAll_EndedRoom_CarriesResult()
{
var host = NewHost();
host.CreateRoom("R", "rps", 1, Cfg(), Players, new Capture());
RoomEndResult result = null;
bool found = false;
for (int i = 0; i < 10 && !found; i++)
foreach (var e in host.TickAll(10f))
if (e.RoomId == "R") { found = true; result = e.Result; }
Assert.True(found);
Assert.NotNull(result); // 夹具已重建,RPS 走 EndRoom(result)
Assert.NotNull(result.ResultBlob);
Assert.NotEmpty(result.ResultBlob); // "最终比分 x : y" 或 "平局 x : y"
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class RoomTests
{
private sealed class FakeOutput : IRoomOutput
{
public int Broadcasts;
public void Broadcast(NetMessage m) => Broadcasts++;
public void SendTo(int playerId, NetMessage m) { }
}
// 正常房间:第 N 次 tick 时通过 ctx.EndRoom 请求结束
private sealed class CountingRoom : IGameServerRoom
{
private IRoomCtx _ctx;
private int _ticks;
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt)
{
_ticks++;
_ctx.Broadcast(new NetMessage(1, new byte[0]));
if (_ticks >= 2) _ctx.EndRoom();
}
public void OnRoomEnd() => EndCalls++;
}
// 故障房间:OnTick 抛异常
private sealed class ThrowingRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) => throw new InvalidOperationException("boom");
public void OnRoomEnd() => EndCalls++;
}
private static (Room room, RoomCtx ctx, T logic) Build<T>(string id, T logic) where T : IGameServerRoom
{
var clock = new ManualClock();
Room room = null;
var ctx = new RoomCtx(new DeterministicRandom(1), clock, new ConsoleLogger(),
new InMemoryStorage(), new FakeOutput(), r => room.RequestEnd(r));
room = new Room(id, logic, clock, ctx, new ConsoleLogger());
return (room, ctx, logic);
}
private static readonly IReadOnlyList<PlayerInfo> Players =
new[] { new PlayerInfo { PlayerId = 1, Name = "P", IsAI = false } };
[Fact]
public void NormalRoom_EndsAfterRequest_AndCallsOnRoomEndOnce()
{
var (room, _, logic) = Build("r1", new CountingRoom());
room.Start(Players);
Assert.True(room.IsActive);
room.Tick(1f); // _ticks=1
Assert.True(room.IsActive);
room.Tick(1f); // _ticks=2 → EndRoom 请求 → 本 tick 后结束
Assert.False(room.IsActive);
Assert.Equal(1, logic.EndCalls);
room.Tick(1f); // 已结束,再 tick 无效
Assert.Equal(1, logic.EndCalls);
}
[Fact]
public void ThrowingRoom_FaultsOut_Safely_WithoutPropagating()
{
var (room, _, logic) = Build("r2", new ThrowingRoom());
room.Start(Players);
var ex = Record.Exception(() => room.Tick(1f)); // 不应向外抛
Assert.Null(ex);
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls); // 故障也走一次安全结束
}
[Fact]
public void ClockAdvances_BeforeOnTick()
{
float seen = -1f;
var capture = new ClockProbeRoom(now => seen = now);
var (room, _, _) = Build("r3", capture);
room.Start(Players);
room.Tick(2f);
Assert.Equal(2f, seen); // OnTick 内可见已推进的时钟
}
// 在 OnRoomStart / OnMessage 抛异常的故障房间
private sealed class ThrowOnStartRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => throw new InvalidOperationException("start boom");
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) { }
public void OnRoomEnd() => EndCalls++;
}
private sealed class ThrowOnMessageRoom : IGameServerRoom
{
public int EndCalls;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) { }
public void OnMessage(int playerId, NetMessage message) => throw new InvalidOperationException("msg boom");
public void OnTick(float dt) { }
public void OnRoomEnd() => EndCalls++;
}
[Fact]
public void ThrowOnStart_FaultsOut_Safely()
{
var (room, _, logic) = Build("rs", new ThrowOnStartRoom());
var ex = Record.Exception(() => room.Start(Players));
Assert.Null(ex); // 不向宿主外抛
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls); // 安全结束一次
}
[Fact]
public void ThrowOnMessage_FaultsOut_Safely()
{
var (room, _, logic) = Build("rm", new ThrowOnMessageRoom());
room.Start(Players);
Assert.True(room.IsActive);
var ex = Record.Exception(() => room.Deliver(1, new NetMessage(0, new byte[0])));
Assert.Null(ex);
Assert.False(room.IsActive);
Assert.True(room.FaultedOut);
Assert.Equal(1, logic.EndCalls);
}
// 结束时带结算结果的房间
private sealed class ResultRoom : IGameServerRoom
{
public RoomEndResult Result;
private IRoomCtx _ctx;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) => _ctx.EndRoom(Result);
public void OnRoomEnd() { }
}
[Fact]
public void EndRoomWithResult_IsCapturedOnRoom()
{
var result = new RoomEndResult { WinnerPlayerId = 7, ResultBlob = new byte[] { 9 } };
var (room, _, _) = Build("rr", new ResultRoom { Result = result });
room.Start(Players);
room.Tick(1f);
Assert.False(room.IsActive);
Assert.Same(result, room.EndResult);
}
[Fact]
public void EndRoomWithoutResult_EndResultIsNull()
{
var (room, _, _) = Build("rn", new CountingRoom()); // CountingRoom 调无参 EndRoom
room.Start(Players);
room.Tick(1f);
room.Tick(1f); // 第 2 tick 触发 EndRoom()
Assert.False(room.IsActive);
Assert.Null(room.EndResult);
}
private sealed class ClockProbeRoom : IGameServerRoom
{
private readonly Action<float> _probe;
private IRoomCtx _ctx;
public ClockProbeRoom(Action<float> probe) { _probe = probe; }
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
public void OnMessage(int playerId, NetMessage message) { }
public void OnTick(float dt) => _probe(_ctx.Timer.Now);
public void OnRoomEnd() { }
}
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using RPS.Core;
namespace XWorld.Server.Host.Tests
{
public class RpsLogicTests
{
private const float Choose = 5f, Reveal = 5f, Total = 60f;
private static RpsState New() => new RpsLogic().CreateInitial(
new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 }, new DeterministicRandom(1));
[Fact]
public void Initial_IsChoosingRound1()
{
var s = New();
Assert.Equal(1, s.Round);
Assert.Equal(RpsPhase.Choosing, s.Phase);
Assert.Equal(Choice.None, s.Choices[0]);
}
[Fact]
public void Choose_LocksSeatChoice_DuringChoosing()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
Assert.Equal(Choice.Rock, s.Choices[0]);
// 二次出招不覆盖
s = logic.Step(s, RpsInput.Choose(0, Choice.Paper), null).State;
Assert.Equal(Choice.Rock, s.Choices[0]);
}
[Fact]
public void ChoosingTimeout_AutoRandomsMissing_AndResolves_RockBeatsScissors()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
// 推进超过 5s 选择阶段 → 结算本回合
var r = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1));
s = r.State;
Assert.Equal(RpsPhase.Revealing, s.Phase);
Assert.Equal(1, s.Scores[0]); // rock 胜 scissors
Assert.Equal(0, s.Scores[1]);
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.RoundResolved && e.RoundWinner == 0);
}
[Fact]
public void Tie_NoScore()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
Assert.Equal(0, s.Scores[0]);
Assert.Equal(0, s.Scores[1]);
}
[Fact]
public void RevealTimeout_StartsNextRound()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State; // → Revealing
s = logic.Step(s, RpsInput.Tick(Reveal), new DeterministicRandom(1)).State; // → next round
Assert.Equal(2, s.Round);
Assert.Equal(RpsPhase.Choosing, s.Phase);
Assert.Equal(Choice.None, s.Choices[0]); // 新回合清空
}
[Fact]
public void TotalTimeout_Finishes_WithWinnerByScore()
{
var logic = new RpsLogic();
var s = New();
// 座位0 赢一回合,然后把总时间推到 60s
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
var r = logic.Step(s, RpsInput.Tick(Total), new DeterministicRandom(1)); // 越过总时长
s = r.State;
Assert.Equal(RpsPhase.Finished, s.Phase);
Assert.Equal(0, s.Winner); // 座位0 分高
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished && e.GameWinner == 0);
}
[Fact]
public void Encode_Decode_RoundTrips()
{
var logic = new RpsLogic();
var s = New();
s.Round = 3; s.Phase = RpsPhase.Revealing; s.Scores[0] = 2; s.Scores[1] = 1;
s.Choices[0] = Choice.Paper; s.Choices[1] = Choice.Rock;
s.TotalElapsed = 12.5f; s.PhaseElapsed = 2.5f;
s.IsAi[0] = false; s.IsAi[1] = true; s.Winner = 1;
var back = logic.Decode(logic.Encode(s));
Assert.Equal(3, back.Round);
Assert.Equal(RpsPhase.Revealing, back.Phase);
Assert.Equal(2, back.Scores[0]);
Assert.Equal(Choice.Paper, back.Choices[0]);
Assert.Equal(Choice.Rock, back.Choices[1]);
Assert.Equal(12.5f, back.TotalElapsed, 3);
Assert.Equal(2.5f, back.PhaseElapsed, 3);
Assert.False(back.IsAi[0]);
Assert.True(back.IsAi[1]);
Assert.Equal(1, back.Winner);
}
[Fact]
public void SameSeed_AutoRandom_IsDeterministic()
{
var logic = new RpsLogic();
RpsState Run(ulong seed)
{
var s = New();
// 双方都不出招,靠超时自动随机
return logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(seed)).State;
}
var a = Run(7); var b = Run(7);
Assert.Equal(a.Choices[0], b.Choices[0]);
Assert.Equal(a.Choices[1], b.Choices[1]);
}
[Fact]
public void TotalTimeout_DuringRevealing_Finishes()
{
var logic = new RpsLogic();
var s = New();
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
s = logic.Step(s, RpsInput.Tick(5f), new DeterministicRandom(1)).State; // → Revealing, seat0 scored
Assert.Equal(RpsPhase.Revealing, s.Phase);
s.TotalElapsed = 58f; // 逼近总时长
var r = logic.Step(s, RpsInput.Tick(2f), new DeterministicRandom(1)); // phase+2(<5 仍 Revealing)total→60 → Finish
s = r.State;
Assert.Equal(RpsPhase.Finished, s.Phase);
Assert.Equal(0, s.Winner);
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished);
}
}
}
@@ -0,0 +1,88 @@
using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;
using RPS.Core;
namespace XWorld.Server.Host.Tests
{
/// <summary>
/// Proves "framework zero-change loads a new game via ALC": loads RPS through the existing ALC contract.
/// Loads RPS from TestGames/rps/1, drives it with 1 human + 1 AI seat, advances past 60s,
/// asserts broadcasts decode and game progresses to Finished.
/// </summary>
public class RpsModuleLoadTests
{
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) { }
}
private static readonly IReadOnlyList<PlayerInfo> Players = new[]
{
new PlayerInfo { PlayerId = 1, Name = "Human", IsAI = false },
new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
};
[Fact]
public void Load_Rps_v1_Via_ALC_ProducesRoom_ThatImplementsSharedInterface()
{
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
Assert.Equal("rps", module.GameId);
Assert.Equal(1, module.Version);
IGameServerRoom room = module.CreateRoom();
Assert.NotNull(room);
// Verify ALC isolation: the implementation type lives in the game ALC,
// not the default context where IGameServerRoom is defined.
Assert.NotEqual(typeof(IGameServerRoom).Assembly, room.GetType().Assembly);
}
[Fact]
public void Load_Rps_v1_Via_ALC_BroadcastsSnapshots_AndProgressesToFinished()
{
var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
IGameServerRoom room = module.CreateRoom();
var output = new FakeOutput();
bool ended = false;
var ctx = new RoomCtx(
new DeterministicRandom(99),
new ManualClock(),
new ConsoleLogger(),
new InMemoryStorage(),
output,
_ => ended = true);
room.OnRoomStart(Players, ctx);
// Initial broadcast on start
Assert.True(output.Broadcasts.Count >= 1);
// Decode the first snapshot to confirm Round=1, Phase=Choosing
var logic = new RpsLogic();
var first = logic.Decode(output.Broadcasts[0].Payload);
Assert.Equal(1, first.Round);
Assert.Equal(RpsPhase.Choosing, first.Phase);
// Drive with large dt to tick through all rounds within 60s total:
// Each tick dt=10f: 10+10+10+10+10+10 = 60s → should finish by tick 6
for (int i = 0; i < 10 && !ended; i++)
room.OnTick(10f);
Assert.True(ended, "EndRoom must have been called (game finished within 10 large ticks)");
Assert.True(output.Broadcasts.Count > 1, "Should have multiple broadcasts");
// Decode the last snapshot — must be Finished
var last = output.Broadcasts[output.Broadcasts.Count - 1];
var finalState = logic.Decode(last.Payload);
Assert.Equal(RpsPhase.Finished, finalState.Phase);
// Winner is 0, 1, or 2 (draw) — must be a valid value
Assert.True(finalState.Winner >= 0 && finalState.Winner <= 2,
$"Winner should be 0, 1, or 2 but was {finalState.Winner}");
}
}
}
@@ -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));
}
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</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="..\Server.Host\Server.Host.csproj" />
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />
<ProjectReference Include="..\games-src\RPS\RPS.Server\RPS.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="TestGames\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>TestGames\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
using System;
using System.IO;
namespace XWorld.Server.Host.Tests
{
internal static class TestGames
{
// 测试工程把 TestGames/** 拷到输出目录;运行时从这里取 games 根
// 前置条件:先运行 build-test-games.sh 生成 TestGames(否则此目录不存在)
public static string Root => Path.Combine(AppContext.BaseDirectory, "TestGames");
}
}
@@ -0,0 +1,9 @@
{
"gameId": "rps",
"version": 1,
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
@@ -0,0 +1,9 @@
{
"gameId": "rps",
"version": 2,
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
@@ -0,0 +1,14 @@
using System;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class ConsoleLogger : ILogger
{
private readonly string _prefix;
public ConsoleLogger(string prefix = "") { _prefix = prefix; }
public void Info(string message) => Console.WriteLine($"[INFO]{_prefix} {message}");
public void Warn(string message) => Console.WriteLine($"[WARN]{_prefix} {message}");
public void Error(string message) => Console.WriteLine($"[ERROR]{_prefix} {message}");
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class InMemoryStorage : IStorage
{
private readonly Dictionary<string, byte[]> _data = new Dictionary<string, byte[]>();
public byte[] Load(string key) => _data.TryGetValue(key, out var v) ? v : null;
public void Save(string key, byte[] data) => _data[key] = data;
}
}
@@ -0,0 +1,11 @@
using XWorld.Framework;
namespace XWorld.Server.Host
{
// 手动推进的时钟;真实运行时改用墙钟实现(子项目 2b/后续)
public sealed class ManualClock : ITimer
{
public float Now { get; private set; }
public void Advance(float dt) { Now += dt; }
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.IO;
using System.Text.Json;
namespace XWorld.Server.Host
{
public sealed class GameManifest
{
public string GameId { get; set; }
public int Version { get; set; }
public string ServerAssembly { get; set; } // 例如 "RPS.Server.dll"
public string ServerEntryType { get; set; } // 例如 "RPS.Server.RpsServerRoom"
public int MinFrameworkVersion { get; set; }
public int PlayerCount { get; set; }
public int TickRateHz { get; set; }
public static GameManifest Parse(string json)
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
GameManifest m;
try { m = JsonSerializer.Deserialize<GameManifest>(json, opts); }
catch (JsonException ex) { throw new InvalidDataException("game.json: 无法解析", ex); }
if (m == null) throw new InvalidDataException("game.json: 内容为空");
if (string.IsNullOrEmpty(m.GameId)) throw new InvalidDataException("game.json: 缺少 gameId");
if (string.IsNullOrEmpty(m.ServerEntryType)) throw new InvalidDataException("game.json: 缺少 serverEntryType");
if (string.IsNullOrEmpty(m.ServerAssembly)) throw new InvalidDataException("game.json: 缺少 serverAssembly");
if (m.Version <= 0) throw new InvalidDataException("game.json: version 必须为正");
return m;
}
public static GameManifest Load(string path) => Parse(File.ReadAllText(path));
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
namespace XWorld.Server.Host
{
// 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文。
// DLL 一律字节加载(LoadFromStream):不持有文件句柄,同版本覆盖重发不被文件锁挡住。
// 代价:字节加载的 Assembly.Location 为空——小游戏 DLL 不得依赖它。
internal sealed class GameModuleAlc : AssemblyLoadContext
{
private readonly string _moduleDir;
public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
{
_moduleDir = moduleDir;
}
protected override Assembly Load(AssemblyName assemblyName)
{
// 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
// 约束:XWorld.Framework.Shared 只能依赖 BCLSystem.*);若将来加第三方依赖,需同步扩展下面白名单。
if (assemblyName.Name == "XWorld.Framework.Shared")
return null;
// BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
string n = assemblyName.Name;
if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
n == "System" || n == "netstandard" || n == "mscorlib"))
return null;
// 小游戏私有程序集放在版本目录里,载入本可卸载上下文
string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
if (File.Exists(candidate))
return LoadBytes(candidate);
return null; // 其余交默认上下文
}
public Assembly LoadEntry(string serverDllPath) => LoadBytes(serverDllPath);
private Assembly LoadBytes(string path)
{
using (var ms = new MemoryStream(File.ReadAllBytes(path)))
return LoadFromStream(ms);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Reflection;
using XWorld.Framework;
namespace XWorld.Server.Host
{
public sealed class GameModuleLoader
{
private readonly string _publicKeyPem;
public GameModuleLoader(string publicKeyPem = null)
{
_publicKeyPem = publicKeyPem;
}
public LoadedGameModule Load(string gamesRoot, string gameId, int version)
{
string moduleDir = Path.Combine(gamesRoot, gameId, version.ToString());
if (!Directory.Exists(moduleDir))
throw new DirectoryNotFoundException($"游戏模块目录不存在: {moduleDir}");
if (_publicKeyPem != null)
VerifySignature(moduleDir);
var manifest = GameManifest.Load(Path.Combine(moduleDir, "game.json"));
if (manifest.GameId != gameId || manifest.Version != version)
throw new InvalidDataException(
$"game.json 与目录不符: 期望 {gameId} v{version}, 实际 {manifest.GameId} v{manifest.Version}");
if (manifest.MinFrameworkVersion > XWorld.Framework.FrameworkInfo.Version)
throw new InvalidOperationException(
$"小游戏要求最小框架版本 {manifest.MinFrameworkVersion},当前框架 {XWorld.Framework.FrameworkInfo.Version},请更新框架后重试");
string serverDll = Path.Combine(moduleDir, manifest.ServerAssembly);
if (!File.Exists(serverDll))
throw new FileNotFoundException($"服务端入口 DLL 不存在: {serverDll}");
var alc = new GameModuleAlc($"{gameId}@{version}", moduleDir);
try
{
Assembly entryAsm = alc.LoadEntry(serverDll);
Type entryType = entryAsm.GetType(manifest.ServerEntryType);
if (entryType == null)
throw new InvalidOperationException(
$"在 {serverDll} 中找不到入口类型 {manifest.ServerEntryType}");
if (!typeof(IGameServerRoom).IsAssignableFrom(entryType))
throw new InvalidOperationException(
$"{manifest.ServerEntryType} 未实现 IGameServerRoom");
Func<IGameServerRoom> factory = () => (IGameServerRoom)Activator.CreateInstance(entryType);
return new LoadedGameModule(gameId, version, alc, factory);
}
catch
{
alc.Unload(); // 失败路径:立刻卸载,避免遗留锁住 DLL 的 collectible ALC
throw;
}
}
// 注意:签名清单仅覆盖 ServerAssembly/CoreDll/game.json(当前小游戏 = Core+Server 两 DLL,完整覆盖)。
// 若将来小游戏带私有第三方 DLL,发布工具(PublishLayout)与此处校验须同步把它们纳入清单。
private void VerifySignature(string moduleDir)
{
string filesTxt = Path.Combine(moduleDir, "files.txt");
string sigPath = Path.Combine(moduleDir, "files.txt.sig");
if (!File.Exists(filesTxt) || !File.Exists(sigPath))
throw new InvalidOperationException($"缺少签名清单,拒绝加载: {moduleDir}");
var fm = XWorld.PublishTool.FilesManifest.Parse(File.ReadAllText(filesTxt));
if (!XWorld.PublishTool.ManifestSigner.Verify(fm.Digest(), File.ReadAllBytes(sigPath), _publicKeyPem))
throw new InvalidOperationException($"签名验证失败,拒绝加载: {moduleDir}");
foreach (var kv in fm.Entries)
{
string p = Path.Combine(moduleDir, kv.Key);
if (!File.Exists(p) || XWorld.PublishTool.Md5Util.OfFile(p) != kv.Value.Md5)
throw new InvalidOperationException($"文件 md5 不符或缺失,拒绝加载: {kv.Key}");
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace XWorld.Server.Host
{
// (gameId,version) -> 已加载模块;按引用计数获取/释放。
// 淘汰有两个来源:①同 gameId 更高版本加载成功;②同 (gameId,version) 磁盘内容指纹变化(覆盖重发)。
// 被淘汰且引用归零即卸载;内容变化时新旧模块并存至旧房间结束(_retired,Release 按实例识别)。
// 生产环境版本不可变、指纹恒命中缓存,行为与旧实现一致。
public sealed class GameModuleRegistry
{
private sealed class Entry
{
public LoadedGameModule Module;
public int RefCount;
public bool Superseded;
public string Fingerprint;
}
private readonly GameModuleLoader _loader;
private readonly string _gamesRoot;
private readonly Dictionary<string, Entry> _current = new Dictionary<string, Entry>();
private readonly List<Entry> _retired = new List<Entry>();
public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
{
_loader = loader; _gamesRoot = gamesRoot;
}
private static string Key(string gameId, int version) => $"{gameId}@{version}";
private string ModuleDir(string gameId, int version) => Path.Combine(_gamesRoot, gameId, version.ToString());
public LoadedGameModule Acquire(string gameId, int version)
{
string key = Key(gameId, version);
if (_current.TryGetValue(key, out var e))
{
// 覆盖重发检测:磁盘内容指纹变化 → 旧条目退役、加载新内容。
// 指纹算不出(目录处于覆盖交换的瞬间窗口/被删)→ 视为未变化,用缓存兜底。
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
if (fp == null || fp == e.Fingerprint)
{
e.RefCount++;
return e.Module;
}
var reloaded = LoadEntry(gameId, version); // 失败直接抛出:旧条目原样保留,不污染状态
Retire(e);
_current[key] = reloaded;
SupersedeOlderVersions(gameId, version);
reloaded.RefCount++;
return reloaded.Module;
}
var loaded = LoadEntry(gameId, version);
_current[key] = loaded;
SupersedeOlderVersions(gameId, version);
loaded.RefCount++;
return loaded.Module;
}
public void Release(LoadedGameModule module)
{
string key = Key(module.GameId, module.Version);
if (_current.TryGetValue(key, out var e) && ReferenceEquals(e.Module, module))
{
if (e.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
e.RefCount--;
if (e.RefCount <= 0 && e.Superseded)
{
_current.Remove(key);
module.Unload();
}
return;
}
// 退役条目:同版本被覆盖重发淘汰后仍被旧房间持有,按实例识别
for (int i = 0; i < _retired.Count; i++)
{
if (!ReferenceEquals(_retired[i].Module, module)) continue;
var r = _retired[i];
if (r.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
r.RefCount--;
if (r.RefCount <= 0)
{
_retired.RemoveAt(i);
module.Unload();
}
return;
}
// 未知模块的释放忽略(与旧实现对不存在 key 直接 return 的行为一致)
}
public int RefCount(string gameId, int version)
=> _current.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;
public bool IsLoaded(string gameId, int version) => _current.ContainsKey(Key(gameId, version));
private Entry LoadEntry(string gameId, int version)
{
// 指纹在加载前采样:加载成功即与所载内容对应(单线程 tick actor 下无并发写窗口)
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
var loaded = _loader.Load(_gamesRoot, gameId, version);
return new Entry { Module = loaded, RefCount = 0, Superseded = false, Fingerprint = fp };
}
private void SupersedeOlderVersions(string gameId, int version)
{
foreach (var kv in _current)
if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
kv.Value.Superseded = true;
}
private void Retire(Entry e)
{
e.Superseded = true;
if (e.RefCount <= 0) e.Module.Unload();
else _retired.Add(e);
}
// 目录内容指纹:全部 *.dll + game.json 按名 Ordinal 排序拼 name=md5; 再整体 md5
// 读不到(目录不存在/IO 竞态)返回 null 由调用方兜底
private static string ComputeFingerprintOrNull(string dir)
{
try
{
if (!Directory.Exists(dir)) return null;
var names = Directory.GetFiles(dir, "*.dll")
.Select(Path.GetFileName)
.Append("game.json")
.Where(n => File.Exists(Path.Combine(dir, n)))
.Distinct()
.OrderBy(n => n, StringComparer.Ordinal);
var sb = new StringBuilder();
foreach (var n in names)
sb.Append(n).Append('=')
.Append(XWorld.PublishTool.Md5Util.OfFile(Path.Combine(dir, n))).Append(';');
return XWorld.PublishTool.Md5Util.OfBytes(Encoding.UTF8.GetBytes(sb.ToString()));
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using XWorld.Framework;
namespace XWorld.Server.Host
{
// 房间出站消息的去向;本子项目无网络,用它对接测试捕获,2b 接 WS 会话
public interface IRoomOutput
{
void Broadcast(NetMessage message);
void SendTo(int playerId, NetMessage message);
}
}

Some files were not shown because too many files have changed in this diff Show More