Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.json,2a 加载约定)与客户端 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` schema(gameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 `game.json` schema(gameId/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 ... # AB(md5 命名)
|
||||
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: Md5Util(TDD)
|
||||
|
||||
**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: FilesManifest(TDD)
|
||||
|
||||
**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: ManifestSigner(RSA 签名/验签,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.json,TDD)
|
||||
|
||||
**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 转 .bytes(md5 命名)+ 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. HybridCLR:CompileDllCommand.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.txt(md5/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)。
|
||||
Reference in New Issue
Block a user