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,580 @@
# 构建/发布工具链(§9 第 4 步)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use `- [ ]`.
>
> 本计划分两半:**(.NET 可 TDD)** 打包/签名/manifest 生成工具(`Server/PublishTool/``dotnet test` 验证);**(Unity 人工核对)** Editor 菜单:Core 双编译为热更 DLL + 打 AssetBundle。Unity 半段标注「[Unity 人工核对]」。
**Goal:** 一键把一个小游戏(Core/Client/Server 源码 + 资源)产出**两端发布包**:服务端目录 `games/{gameId}/{version}/`Server.dll + Core.dll + game.json2a 加载约定)与客户端 CDN 目录 `minigame/{gameId}/{version}/`Core/Client 热更 DLL `.bytes` + AB + game.json + files.txt + 签名,第 3 步消费约定);版本号绑定,一次构建产出两端,避免逻辑漂移(设计 §3.1)。
**Architecture:** Core 按 A1 编两次(客户端 HybridCLR 热更 DLL + 服务端普通 .NET DLL)——这步依赖 Unity/HybridCLR 工具链,做成 Editor 菜单(人工)。所有**与平台无关的打包工作**(计算 md5、写 files.txt、生成两份 game.json、对清单签名、按目录布局落盘)抽到 `Server/PublishTool` .NET 库,`dotnet test` 全程可验。客户端/服务端加载前用同一 `ManifestSigner` 验签(设计 §7)。
**Tech Stack:** .NET 10 控制台/库 `Server/PublishTool` · `System.Security.Cryptography`(MD5 文件指纹 + RSA 清单签名)· xUnit · Unity Editor 菜单(HybridCLR `CompileDllCommand` + `BuildAssetBundles`,人工)。
---
## 背景与约定
- 上游:设计 §3.1(发布流程)、§7(签名校验)。消费方:2a 服务端 `GameModuleLoader``games/{id}/{ver}/`)、第 3 步客户端 `MiniGameDownloader``minigame/{id}/{ver}/`)。
- 已完成 2a 的服务端 `game.json` schemagameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 `game.json` schemagameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion/assets)。本工具**两份都生成**。
- **无 git**:跳过 git 步骤。工作目录 `D:/UD/AI/AIC#Project``dotnet` SDK 10。
## 发布产物布局(本工具产出)
```
<outRoot>/server/games/{gameId}/{version}/
{Game}.Server.dll {Game}.Core.dll game.json # 2a 加载(无 .bytes,普通 DLL
<outRoot>/client/minigame/{gameId}/{version}/
{Game}.Core.dll.bytes {Game}.Client.dll.bytes # HybridCLR 热更 DLL(字节流)
{abName}_{md5}.unity3d ... # ABmd5 命名)
game.json files.txt files.txt.sig # 客户端清单 + 签名
```
## 文件结构
| 文件 | 职责 |
|---|---|
| `Server/PublishTool/PublishTool.csproj` | net10.0 库 |
| `Server/PublishTool/Md5Util.cs` | 文件/字节 md5 十六进制 |
| `Server/PublishTool/ManifestSigner.cs` | RSA 对 files.txt 摘要签名 + 验签(客户端/服务端加载前用) |
| `Server/PublishTool/FilesManifest.cs` | files.txt 模型 + 生成(`{'name','md5',size},` 行)+ 解析 |
| `Server/PublishTool/GameJsonWriter.cs` | 生成服务端 + 客户端两份 game.json |
| `Server/PublishTool/PublishLayout.cs` | 输入产物 → 落盘到上述目录布局 + 写 manifest + 签名 |
| `Server/PublishTool.Tests/*` | xUnit |
| `Client/Assets/Editor/MiniGamePublishMenu.cs` | [Unity] 菜单:Core 双编译 + 打 AB + 调 PublishLayout |
---
## Task 1: 工程脚手架
**Files:** Create `Server/PublishTool/PublishTool.csproj`, `Server/PublishTool.Tests/PublishTool.Tests.csproj`; modify `Server/Server.sln`.
- [ ] **Step 1:**
```bash
dotnet new classlib -n PublishTool -o Server/PublishTool -f net10.0
dotnet new xunit -n PublishTool.Tests -o Server/PublishTool.Tests
rm -f Server/PublishTool/Class1.cs Server/PublishTool.Tests/UnitTest1.cs
dotnet sln Server/Server.sln add Server/PublishTool/PublishTool.csproj
dotnet sln Server/Server.sln add Server/PublishTool.Tests/PublishTool.Tests.csproj
```
- [ ] **Step 2:**`PublishTool.Tests.csproj``<Nullable>disable</Nullable>` + `<ProjectReference Include="..\PublishTool\PublishTool.csproj" />``PublishTool.csproj``<Nullable>disable</Nullable>`
- [ ] **Step 3:** Run `dotnet build Server/Server.sln -v minimal` → 0 error。
---
## Task 2: Md5UtilTDD
**Files:** Create `Server/PublishTool/Md5Util.cs`; Test `Server/PublishTool.Tests/Md5UtilTests.cs`.
- [ ] **Step 1: 失败测试**
```csharp
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]));
}
}
}
```
- [ ] **Step 2:** Run `dotnet test Server/Server.sln --filter Md5UtilTests` → RED。
- [ ] **Step 3: 实现**
```csharp
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));
}
}
```
- [ ] **Step 4:** Run filter → GREEN。
---
## Task 3: FilesManifestTDD
**Files:** Create `Server/PublishTool/FilesManifest.cs`; Test `Server/PublishTool.Tests/FilesManifestTests.cs`.
- [ ] **Step 1: 失败测试**
```csharp
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 排序后摘要,顺序无关
}
}
}
```
- [ ] **Step 2:** RED。
- [ ] **Step 3: 实现**
```csharp
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();
}
}
}
```
- [ ] **Step 4:** GREEN。
---
## Task 4: ManifestSignerRSA 签名/验签,TDD
**Files:** Create `Server/PublishTool/ManifestSigner.cs`; Test `Server/PublishTool.Tests/ManifestSignerTests.cs`.
> 发布方用私钥签 files.txt 的 `Digest()`,产出 `files.txt.sig`;客户端/服务端加载前用内置公钥验签(设计 §7:防中间人篡改 DLL)。
- [ ] **Step 1: 失败测试**
```csharp
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()));
}
}
}
```
- [ ] **Step 2:** RED。
- [ ] **Step 3: 实现**
```csharp
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);
}
}
}
```
- [ ] **Step 4:** GREEN。
---
## Task 5: GameJsonWriter(两份 game.jsonTDD
**Files:** Create `Server/PublishTool/GameJsonWriter.cs`; Test `Server/PublishTool.Tests/GameJsonWriterTests.cs`.
> 用 `System.Text.Json` 生成。服务端 schema 与 2a `GameManifest` 字段一致;客户端 schema 与第 3 步 `MiniGameManifest` 一致。用 2a 的 `GameManifest.Parse`/第 3 步字段名做往返断言(这里直接断言 JSON 含正确字段值)。
- [ ] **Step 1: 失败测试**
```csharp
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",
Assets = new System.Collections.Generic.List<string> { "ui_rps" }
};
[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("ui_rps", r.GetProperty("assets")[0].GetString());
}
}
}
```
- [ ] **Step 2:** RED。
- [ ] **Step 3: 实现**
```csharp
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 List<string> Assets = new List<string>();
}
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,
assets = s.Assets
}, Opts);
}
}
```
- [ ] **Step 4:** GREEN。
---
## Task 6: PublishLayout(落盘布局 + 清单 + 签名,TDD)
**Files:** Create `Server/PublishTool/PublishLayout.cs`; Test `Server/PublishTool.Tests/PublishLayoutTests.cs`.
> 输入:一个已构建产物的临时目录(Server.dll/Core.dll/Client.dll/AB+ GameSpec + 私钥。输出:server/ 与 client/ 两套目录 + game.json×2 + files.txt + files.txt.sig。用临时目录测全流程,再用公钥验签 + 重新 md5 校验。
- [ ] **Step 1: 失败测试**
```csharp
using System.IO;
using System.Security.Cryptography;
using Xunit;
using XWorld.PublishTool;
namespace XWorld.PublishTool.Tests
{
public class PublishLayoutTests
{
[Fact]
public void Publish_ProducesBothTrees_WithValidSignatureAndMd5()
{
// 准备假产物
string src = Path.Combine(Path.GetTempPath(), "pubsrc_" + System.Guid.NewGuid().ToString("N"));
string outRoot = Path.Combine(Path.GetTempPath(), "pubout_" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(src);
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 });
File.WriteAllBytes(Path.Combine(src, "RPS.Client.dll"), new byte[] { 6, 7, 8, 9 });
File.WriteAllBytes(Path.Combine(src, "ui_rps.unity3d"), new byte[] { 10 });
using var rsa = RSA.Create(2048);
var spec = 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",
};
spec.Assets.Add("ui_rps");
new PublishLayout().Publish(src, outRoot, spec, rsa.ExportRSAPrivateKeyPem());
string serverDir = Path.Combine(outRoot, "server", "games", "rps", "1");
string clientDir = Path.Combine(outRoot, "client", "minigame", "rps", "1");
// 服务端:普通 DLL + game.json
Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Server.dll")));
Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Core.dll")));
Assert.True(File.Exists(Path.Combine(serverDir, "game.json")));
// 客户端:热更 DLL 转 .bytesmd5 命名)+ AB(md5名) + game.json + files.txt + sig
string coreMd5 = Md5Util.OfFile(Path.Combine(src, "RPS.Core.dll"));
string abMd5 = Md5Util.OfFile(Path.Combine(src, "ui_rps.unity3d"));
Assert.True(File.Exists(Path.Combine(clientDir, $"RPS.Core.dll_{coreMd5}.bytes")));
Assert.True(File.Exists(Path.Combine(clientDir, $"ui_rps_{abMd5}.unity3d")));
Assert.True(File.Exists(Path.Combine(clientDir, "game.json")));
Assert.True(File.Exists(Path.Combine(clientDir, "files.txt")));
Assert.True(File.Exists(Path.Combine(clientDir, "files.txt.sig")));
// 验签:用公钥验证 files.txt 的 digest
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(clientDir, "files.txt")));
byte[] sig = File.ReadAllBytes(Path.Combine(clientDir, "files.txt.sig"));
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
Directory.Delete(src, true); Directory.Delete(outRoot, true);
}
}
}
```
- [ ] **Step 2:** RED。
- [ ] **Step 3: 实现**
```csharp
using System.IO;
namespace XWorld.PublishTool
{
// 把构建产物落成两端发布目录 + 清单 + 签名
public sealed class PublishLayout
{
public void Publish(string srcDir, string outRoot, GameSpec spec, string privateKeyPem)
{
string serverDir = Path.Combine(outRoot, "server", "games", spec.GameId, spec.Version.ToString());
string clientDir = Path.Combine(outRoot, "client", "minigame", spec.GameId, spec.Version.ToString());
Directory.CreateDirectory(serverDir);
Directory.CreateDirectory(clientDir);
// 服务端:普通 DLL 原样拷 + game.json
CopyTo(srcDir, spec.ServerAssembly, serverDir, spec.ServerAssembly);
CopyTo(srcDir, spec.CoreDll, serverDir, spec.CoreDll);
File.WriteAllText(Path.Combine(serverDir, "game.json"), GameJsonWriter.WriteServer(spec));
// 客户端:Core/Client 转 .bytes(热更字节流)+ AB(md5名) + 清单
var fm = new FilesManifest();
string coreBytes = spec.CoreDll + ".bytes";
string clientBytes = spec.ClientDll + ".bytes";
CopyBytes(srcDir, spec.CoreDll, clientDir, coreBytes, fm);
CopyBytes(srcDir, spec.ClientDll, clientDir, clientBytes, fm);
foreach (var ab in spec.Assets)
{
string abFile = ab + ".unity3d";
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, abFile));
string md5 = Md5Util.OfBytes(data);
// md5 命名落盘:name_md5.unity3d
string md5Name = ab + "_" + md5 + ".unity3d";
File.WriteAllBytes(Path.Combine(clientDir, md5Name), data);
fm.Add(abFile, md5, data.Length);
}
File.WriteAllText(Path.Combine(clientDir, "game.json"), GameJsonWriter.WriteClient(spec));
string filesTxt = fm.Render();
File.WriteAllText(Path.Combine(clientDir, "files.txt"), filesTxt);
byte[] sig = ManifestSigner.Sign(fm.Digest(), privateKeyPem);
File.WriteAllBytes(Path.Combine(clientDir, "files.txt.sig"), sig);
}
private static void CopyTo(string srcDir, string srcName, string dstDir, string dstName)
=> File.Copy(Path.Combine(srcDir, srcName), Path.Combine(dstDir, dstName), true);
// 落盘为 md5 命名(name_md5.ext,与 AB / LoadDll 约定及下载器 Md5Name 推导一致),files.txt 登记逻辑名
private static void CopyBytes(string srcDir, string srcDll, string dstDir, string logicalBytesName, FilesManifest fm)
{
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, srcDll));
string md5 = Md5Util.OfBytes(data);
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(logicalBytesName, md5)), data);
fm.Add(logicalBytesName, 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}";
}
}
}
```
- [ ] **Step 4:** GREEN。
- [ ] **Step 5:** Run `dotnet test Server/Server.sln` 全量绿,0 警告。
---
## Task 7: [Unity 人工核对] Editor 发布菜单
**Files:** Create `Client/Assets/Editor/MiniGamePublishMenu.cs`(仅 Editor 编译)。
> 这一段依赖 Unity/HybridCLR**无法在 .NET 沙箱验证**,作为人工核对实施。
- [ ] **Step 1: 写 `MiniGamePublishMenu.cs`(要点,非完整)**
```csharp
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
// using HybridCLR.Editor.Commands; // CompileDllCommand
namespace XGame.Editor
{
public static class MiniGamePublishMenu
{
[MenuItem("XWorld/发布小游戏/RPS")]
public static void PublishRps()
{
// 1) Core 双编译:
// a. HybridCLRCompileDllCommand.CompileDll(target) 产出热更 RPS.Core.dll / RPS.Client.dll(裁剪后字节)
// b. 服务端 .NET:调用 `dotnet build` RPS.Core(netstandard2.1)/RPS.Server 产出普通 DLL(或复用 Server 解决方案构建产物)
// 2) BuildPipeline.BuildAssetBundles(...) 打小游戏 AB(按目录命名),输出 ui_rps.unity3d
// 3) 把以上产物汇集到临时 srcDir
// 4) 调 .NET 工具:new XWorld.PublishTool.PublishLayout().Publish(srcDir, outRoot, spec, privateKeyPem)
// PublishTool 编成 DLL 供 Editor 引用,或用命令行调用一个 PublishTool 控制台封装)
Debug.Log("发布完成(人工核对产物布局/签名)");
}
}
}
#endif
```
- [ ] **Step 2: [Unity 人工核对]** 在 Editor 跑菜单,确认产出 `server/games/rps/1/``client/minigame/rps/1/`,文件齐全、签名可验(用 PublishTool 的 `ManifestSigner.Verify`)、md5 一致。Core 的两份 DLL(热更字节 vs 普通 .NET)来自**同一份 Core 源码**A1)。
---
## 完成判据
- [ ] `.NET` 段:`dotnet test Server/Server.sln` 全绿(Md5Util/FilesManifest/ManifestSigner/GameJsonWriter/PublishLayout 各测试),0 警告。
- [ ] `PublishLayout.Publish` 产出两端目录、两份 game.json(字段分别匹配 2a 服务端 schema 与第 3 步客户端 schema)、files.txtmd5/size)、files.txt.sig(公钥可验、篡改即失败)。
- [ ] [Unity 人工核对] Editor 菜单完成 Core 双编译 + 打 AB + 调 PublishLayout,产物可被 2a 服务端 `GameModuleLoader` 与第 3 步客户端 `MiniGameDownloader` 直接消费。
## 不在本步骤范围 / 风险
- 真正的「发布服务」后台(灰度/正式通道、CDN 上传、版本库)——本步骤只产出**本地布局**;上传/通道是运维集成,后续。
- 客户端/服务端**加载前验签**的接线:把 `ManifestSigner.Verify` + 内置公钥接进第 3 步 `MiniGameDownloader`(客户端)与 2a `GameModuleLoader`(服务端)——建议作为各自的小增量补上(本工具已提供可复用的 `ManifestSigner`/`FilesManifest`)。
- 微信 WASM 热更合规(§6.6)。