Files
AIC-Project/docs/superpowers/plans/2026-06-19-publish-toolchain-step4.md
2026-07-10 10:24:29 +08:00

581 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 构建/发布工具链(§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)。