Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,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.");
}
}
}