Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.txt(files.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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)出 Rock:opcode=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); // 第一大 tick:TotalElapsed=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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"gameId": "rps",
|
||||
"version": 1,
|
||||
"serverAssembly": "RPS.Server.dll",
|
||||
"serverEntryType": "RPS.Server.RpsServerRoom",
|
||||
"minFrameworkVersion": 1,
|
||||
"playerCount": 2,
|
||||
"tickRateHz": 10
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"gameId": "rps",
|
||||
"version": 2,
|
||||
"serverAssembly": "RPS.Server.dll",
|
||||
"serverEntryType": "RPS.Server.RpsServerRoom",
|
||||
"minFrameworkVersion": 1,
|
||||
"playerCount": 2,
|
||||
"tickRateHz": 10
|
||||
}
|
||||
Reference in New Issue
Block a user