# 小游戏打包流程(XWorld/生成小游戏更新)实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Unity 菜单「XWorld/生成小游戏更新」:选一个小游戏 → 打客户端代码 AB + 资源 AB 到 `CDN/minigame////`,编服务器 DLL 到 `deploy/minigame///`。 **Architecture:** 方案 B——Editor 只做 Unity 能做的(弹窗、dotnet build、BuildAssetBundles、staging),清单/md5/签名/落位由改造后的 `Server/PublishTool` 承担,Editor 经新增瘦壳 `PublishTool.Cli` 进程调用。同步落地「统一 AB」:客户端运行时(Manifest/Downloader/AssemblyLoader/Host)改为从代码 AB 取 DLL、新增资源 AB 加载器。 **Tech Stack:** Unity 2022.3 Editor API(EditorWindow/BuildPipeline)、.NET 10(PublishTool + xUnit)、netstandard2.1(小游戏 DLL)。 **规格:** `docs/superpowers/specs/2026-07-07-minigame-publish-pipeline-design.md` **重要约定(本仓库特有):** - **无 git**(`.git` 是空目录,用户明确不用 git)。所有「commit」步骤替换为「验证 checkpoint」。 - 本环境**无 Unity**:Editor/运行时 C# 代码无法在此编译运行。.NET 侧一律 `dotnet test`/`dotnet build` 实证;Unity 侧用 Task 8 的 Verify csproj 做 csc 级编译验证 + 文末人工核对清单。 - 仓库根记为 `` = `D:\UD\AI\AIC#Project`。所有 dotnet 命令在 `` 下执行。 - 现有基线:`dotnet test Server/Server.sln` = 171 通过 / 0 失败。每个 .NET 任务结束必须回到全绿。 --- ## 文件结构总览 | 动作 | 路径 | 职责 | |---|---|---| | 改 | `Server/PublishTool/GameJsonWriter.cs` | GameSpec 加 `CodeAb`;WriteClient 输出 `codeAb` | | 建 | `Server/PublishTool/GameSpecJson.cs` | 解析 resolved-spec.json → GameSpec | | 改 | `Server/PublishTool/PublishLayout.cs` | 拆 PublishServer/PublishClient;统一 AB 布局;key 可空 | | 建 | `Server/PublishTool/CliOptions.cs` | CLI 参数解析(可测) | | 建 | `Server/PublishTool.Cli/PublishTool.Cli.csproj` + `Program.cs` | 瘦壳:parse → PublishLayout | | 改 | `Server/Server.sln` | 挂 PublishTool.Cli | | 改 | `Server/PublishTool.Tests/GameJsonWriterTests.cs`、`PublishLayoutTests.cs`;建 `GameSpecJsonTests.cs`、`CliOptionsTests.cs` | 测试 | | 改 | `Server/games-src/RPS/RPS.Core/RPS.Core.csproj`(删同目录 RpsLogic.cs/RpsTypes.cs) | Core 单源:链接 scripts~/Core | | 改 | `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` | 只编 scripts~/Client + 引用 RPS.Core | | 建 | `Client/Assets/MiniGames/RockPaperScissors/publish.json` | RPS 发布元数据 | | 建 | `Client/Assets/Script/xmain/MiniGame/MiniGamePlatform.cs` | 平台名 + 平台相关 AB 打开(WebGL 走 LoadFromMemory) | | 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs` | 解析/校验 `codeAb` | | 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs` | RemoteBase 加平台段 | | 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs` | 从代码 AB 取 DLL 字节 | | 建 | `Client/Assets/Script/xmain/MiniGame/MiniGameAssetLoader.cs` | 资源 AB 的 IAssetLoader | | 改 | `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` | 注入新 loader + ExitGame 卸 AB | | 建 | `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj` | 运行时改动的 dotnet 编译验证壳 | | 改 | `Client/Assets/Editor/MiniGamePublishMenu.cs` | 菜单 + EditorWindow | | 建 | `Client/Assets/Editor/MiniGamePublishPipeline.cs` | 流水线(build/AB/CLI/落位) | | 改 | `Client/Assets/Script/Editor/XWorldUtil.cs` | 本地网关 gamesRoot → deploy/minigame | | 删 | `Tools/PcMiniGameSmoke/`(ps1 + README) | 废弃旧 smoke 打包 | 新建的 `Client/Assets/**` .cs 文件不手写 .meta——由 Unity 首次打开时生成(本仓库惯例)。 --- ### Task 1: GameSpec 加 CodeAb + GameJsonWriter 输出 codeAb **Files:** - Modify: `Server/PublishTool/GameJsonWriter.cs` - Modify: `Server/PublishTool.Tests/GameJsonWriterTests.cs` - [ ] **Step 1.1: 写失败测试** —— 修改 `GameJsonWriterTests.cs`:Spec 夹具加 `CodeAb`、Assets 改为完整文件名,`Client_Json_HasRequiredFields` 加 codeAb 断言: ```csharp 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 { "res.unity3d" } }; ``` `Client_Json_HasRequiredFields` 内追加/替换断言: ```csharp Assert.Equal("code.unity3d", r.GetProperty("codeAb").GetString()); Assert.Equal("res.unity3d", r.GetProperty("assets")[0].GetString()); ``` (原 `Assert.Equal("ui_rps", ...)` 删除。) - [ ] **Step 1.2: 跑测试确认失败** Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameJsonWriterTests 2>&1 | tail -5` Expected: 编译失败 `CS0117 'GameSpec' does not contain a definition for 'CodeAb'` - [ ] **Step 1.3: 实现** —— `GameJsonWriter.cs`: GameSpec 类加字段(`ClientEntryType` 行后): ```csharp public string ServerAssembly; public string ServerEntryType; public string CoreDll; public string ClientDll; public string ClientEntryType; public string CodeAb; // 代码 AB 文件名(统一 AB:DLL .bytes 打进它) public List Assets = new List(); // 资源 AB 完整文件名列表 ``` `WriteClient` 改为: ```csharp 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, codeAb = s.CodeAb, assets = s.Assets }, Opts); ``` - [ ] **Step 1.4: 跑测试确认通过** Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameJsonWriterTests 2>&1 | tail -5` Expected: PASS(2 tests)。注意 `PublishLayoutTests` 此时仍引用旧布局——它在 Task 3 重写;若本步全项目编译失败即是它,可暂时容忍 filter 级通过、Task 3 收口。 --- ### Task 2: GameSpecJson(resolved-spec.json → GameSpec) **Files:** - Create: `Server/PublishTool/GameSpecJson.cs` - Create: `Server/PublishTool.Tests/GameSpecJsonTests.cs` - [ ] **Step 2.1: 写失败测试** `GameSpecJsonTests.cs`: ```csharp 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); } } } ``` - [ ] **Step 2.2: 跑测试确认失败** Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameSpecJsonTests 2>&1 | tail -5` Expected: 编译失败 `GameSpecJson` 不存在 - [ ] **Step 2.3: 实现** `GameSpecJson.cs`: ```csharp using System.IO; using System.Text.Json; namespace XWorld.PublishTool { // resolved-spec.json(Editor 侧 JsonUtility 产出,字段名小写)→ GameSpec。 // GameSpec 用 public 字段,故必须 IncludeFields;键名大小写不敏感。 public static class GameSpecJson { private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions { IncludeFields = true, PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; public static GameSpec Parse(string json) { var s = JsonSerializer.Deserialize(json, Opts); if (s.Assets == null) s.Assets = new System.Collections.Generic.List(); return s; } public static GameSpec ParseFile(string path) => Parse(File.ReadAllText(path)); } } ``` - [ ] **Step 2.4: 跑测试确认通过** Run: `dotnet test Server/PublishTool.Tests -nologo --filter GameSpecJsonTests 2>&1 | tail -5` Expected: PASS(2 tests)。若 `PropertyNameCaseInsensitive` 对字段不生效(第一个测试字段全空)——改用 `JsonNamingPolicy.CamelCase` 无效(同为属性向),则给 GameSpec 字段逐个加 `[JsonPropertyName]` 不可行(是字段可行):在 `GameJsonWriter.cs` 的每个 GameSpec 字段上加 `[System.Text.Json.Serialization.JsonPropertyName("gameId")]` 等注解后重跑。 --- ### Task 3: PublishLayout 拆分 + 统一 AB 布局 + 可选签名 **Files:** - Modify: `Server/PublishTool/PublishLayout.cs`(整文件替换) - Modify: `Server/PublishTool.Tests/PublishLayoutTests.cs`(整文件替换) - [ ] **Step 3.1: 重写测试** `PublishLayoutTests.cs` 整文件替换为: ```csharp 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 { "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( () => new PublishLayout().PublishClient(TempDir(), Path.Combine(TempDir(), "c"), spec, null)); } } } ``` - [ ] **Step 3.2: 跑测试确认失败** Run: `dotnet test Server/PublishTool.Tests -nologo --filter PublishLayoutTests 2>&1 | tail -5` Expected: 编译失败 `PublishServer` 不存在 - [ ] **Step 3.3: 实现** —— `PublishLayout.cs` 整文件替换为: ```csharp using System; using System.IO; namespace XWorld.PublishTool { // 把构建产物落成发布目录 + 清单 + (可选)签名。 // privateKeyPem 传 null = 不签名:files.txt.sig 落空文件(两端验签均为"未配置则跳过")。 public sealed class PublishLayout { // 服务器树:outDir 即 /// 的内容(普通文件名,不做 md5 命名) public void PublishServer(string srcDir, string outDir, GameSpec spec, string privateKeyPem) { Directory.CreateDirectory(outDir); CopyTo(srcDir, spec.ServerAssembly, outDir); CopyTo(srcDir, spec.CoreDll, outDir); // game.json 先写,确保其 md5 纳入清单 File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteServer(spec)); var fm = new FilesManifest(); foreach (var name in new[] { spec.ServerAssembly, spec.CoreDll, "game.json" }) { byte[] b = File.ReadAllBytes(Path.Combine(outDir, name)); fm.Add(name, Md5Util.OfBytes(b), b.Length); } WriteManifestAndSig(outDir, fm, privateKeyPem); } // 客户端树:outDir 即 CDN/minigame//// 的内容。 // spec.CodeAb / spec.Assets 均为完整 AB 文件名(code.unity3d / res.unity3d),统一 AB:无散 .bytes。 public void PublishClient(string srcDir, string outDir, GameSpec spec, string privateKeyPem) { if (string.IsNullOrEmpty(spec.CodeAb)) throw new InvalidOperationException("spec.CodeAb 为空:客户端发布需要代码 AB(统一 AB 格式)"); Directory.CreateDirectory(outDir); var fm = new FilesManifest(); CopyMd5Named(srcDir, spec.CodeAb, outDir, fm); foreach (var ab in spec.Assets) CopyMd5Named(srcDir, ab, outDir, fm); File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteClient(spec)); WriteManifestAndSig(outDir, fm, privateKeyPem); } private static void WriteManifestAndSig(string outDir, FilesManifest fm, string privateKeyPem) { File.WriteAllText(Path.Combine(outDir, "files.txt"), fm.Render()); byte[] sig = privateKeyPem == null ? Array.Empty() : ManifestSigner.Sign(fm.Digest(), privateKeyPem); File.WriteAllBytes(Path.Combine(outDir, "files.txt.sig"), sig); } private static void CopyTo(string srcDir, string name, string dstDir) => File.Copy(Path.Combine(srcDir, name), Path.Combine(dstDir, name), true); // 落盘 md5 命名(name_md5.ext,与 MiniGameDownloader.Md5Name 推导一致),files.txt 登记逻辑名 private static void CopyMd5Named(string srcDir, string name, string dstDir, FilesManifest fm) { byte[] data = File.ReadAllBytes(Path.Combine(srcDir, name)); string md5 = Md5Util.OfBytes(data); File.WriteAllBytes(Path.Combine(dstDir, Md5Name(name, md5)), data); fm.Add(name, 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 3.4: 跑 PublishTool.Tests 全量确认通过** Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5` Expected: 全 PASS(原 13 - 旧 PublishLayout 1 + 新 4 + GameSpecJson 2 = 18 个左右;关键是 0 失败) - [ ] **Step 3.5: Checkpoint——全解决方案回归** Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8` Expected: 0 失败(Server.Host 引用 PublishTool 的 FilesManifest/ManifestSigner 未动,应无回归) --- ### Task 4: PublishTool.Cli(瘦壳 + 参数解析) **Files:** - Create: `Server/PublishTool/CliOptions.cs` - Create: `Server/PublishTool.Tests/CliOptionsTests.cs` - Create: `Server/PublishTool.Cli/PublishTool.Cli.csproj` - Create: `Server/PublishTool.Cli/Program.cs` - Modify: `Server/Server.sln` - [ ] **Step 4.1: 写失败测试** `CliOptionsTests.cs`: ```csharp 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(() => CliOptions.Parse(args)); } } ``` - [ ] **Step 4.2: 跑测试确认失败** Run: `dotnet test Server/PublishTool.Tests -nologo --filter CliOptionsTests 2>&1 | tail -5` Expected: 编译失败 `CliOptions` 不存在 - [ ] **Step 4.3: 实现** `Server/PublishTool/CliOptions.cs`: ```csharp using System; namespace XWorld.PublishTool { // PublishTool.Cli 的参数:--mode client|server --src --out --spec [--key ] public sealed class CliOptions { public string Mode; // "client" | "server" public string Src; // 构建产物目录 public string Out; // 落位目录(版本目录本体) public string SpecPath; // resolved-spec.json public string KeyPath; // 私钥 pem,可空=不签名 public const string Usage = "用法: PublishTool.Cli --mode client|server --src --out --spec [--key ]"; public static CliOptions Parse(string[] args) { var o = new CliOptions(); for (int i = 0; i < args.Length; i += 2) { if (i + 1 >= args.Length) throw new ArgumentException($"缺少 {args[i]} 的值\n{Usage}"); string v = args[i + 1]; switch (args[i]) { case "--mode": o.Mode = v; break; case "--src": o.Src = v; break; case "--out": o.Out = v; break; case "--spec": o.SpecPath = v; break; case "--key": o.KeyPath = v; break; default: throw new ArgumentException($"未知参数 {args[i]}\n{Usage}"); } } if (o.Mode != "client" && o.Mode != "server") throw new ArgumentException($"--mode 必须是 client 或 server\n{Usage}"); if (string.IsNullOrEmpty(o.Src) || string.IsNullOrEmpty(o.Out) || string.IsNullOrEmpty(o.SpecPath)) throw new ArgumentException($"--src/--out/--spec 均必填\n{Usage}"); return o; } } } ``` - [ ] **Step 4.4: 跑测试确认通过** Run: `dotnet test Server/PublishTool.Tests -nologo --filter CliOptionsTests 2>&1 | tail -5` Expected: PASS(5 个用例) - [ ] **Step 4.5: 建 Cli 工程** `Server/PublishTool.Cli/PublishTool.Cli.csproj`: ```xml Exe net10.0 disable disable XWorld.PublishTool.Cli XWorld.PublishTool.Cli ``` `Server/PublishTool.Cli/Program.cs`: ```csharp using System; using System.IO; using XWorld.PublishTool; namespace XWorld.PublishTool.Cli { // 瘦壳:参数解析 → PublishLayout(全部逻辑在 PublishTool 库,有 xUnit 覆盖) internal static class Program { // 退出码:0 成功;1 参数错误;2 发布失败 private static int Main(string[] args) { CliOptions o; try { o = CliOptions.Parse(args); } catch (ArgumentException e) { Console.Error.WriteLine(e.Message); return 1; } try { GameSpec spec = GameSpecJson.ParseFile(o.SpecPath); string key = o.KeyPath == null ? null : File.ReadAllText(o.KeyPath); var layout = new PublishLayout(); if (o.Mode == "server") layout.PublishServer(o.Src, o.Out, spec, key); else layout.PublishClient(o.Src, o.Out, spec, key); Console.WriteLine($"published {o.Mode} {spec.GameId}/{spec.Version} -> {o.Out}" + (key == null ? " [WARN] 未签名(files.txt.sig 为空)" : " [signed]")); return 0; } catch (Exception e) { Console.Error.WriteLine("publish failed: " + e.Message); return 2; } } } } ``` - [ ] **Step 4.6: 挂进 sln 并构建** Run: ```bash cd "D:/UD/AI/AIC#Project" && dotnet sln Server/Server.sln add Server/PublishTool.Cli/PublishTool.Cli.csproj && dotnet build Server/PublishTool.Cli -c Release -nologo 2>&1 | tail -3 ``` Expected: `0 个错误` - [ ] **Step 4.7: CLI 端到端冒烟(bash,真实文件)** ```bash cd "D:/UD/AI/AIC#Project" T=$(mktemp -d) printf 'CORE' > "$T/RPS.Core.dll"; printf 'SRV' > "$T/RPS.Server.dll" printf 'AB1' > "$T/code.unity3d"; printf 'AB2' > "$T/res.unity3d" cat > "$T/spec.json" <<'EOF' {"gameId":"rps","version":9,"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"]} EOF CLI=Server/PublishTool.Cli/bin/Release/net10.0/XWorld.PublishTool.Cli.dll dotnet "$CLI" --mode server --src "$T" --out "$T/out-server" --spec "$T/spec.json" dotnet "$CLI" --mode client --src "$T" --out "$T/out-client" --spec "$T/spec.json" ls "$T/out-server" "$T/out-client" && cat "$T/out-client/files.txt" rm -rf "$T" ``` Expected: 两次 exit 0、输出含 `[WARN] 未签名`;out-server 有 `RPS.Server.dll RPS.Core.dll game.json files.txt files.txt.sig`;out-client 有 `code_.unity3d res_.unity3d game.json files.txt files.txt.sig`;files.txt 两行登记逻辑名。 - [ ] **Step 4.8: Checkpoint——全量回归** Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8` Expected: 0 失败 --- ### Task 5: RPS Core 单源(csproj 修正) **Files:** - Modify: `Server/games-src/RPS/RPS.Core/RPS.Core.csproj` - Delete: `Server/games-src/RPS/RPS.Core/RpsLogic.cs`、`Server/games-src/RPS/RPS.Core/RpsTypes.cs` - Modify: `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` - [ ] **Step 5.1: 先核对两份 Core 源码语义等价**(删除前提;已知有格式漂移) ```bash cd "D:/UD/AI/AIC#Project" diff <(sed 's/\/\/.*//' "Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/RpsLogic.cs" | tr -d ' \t\r\n') \ <(sed 's/\/\/.*//' "Server/games-src/RPS/RPS.Core/RpsLogic.cs" | tr -d ' \t\r\n') && echo LOGIC-EQ || echo LOGIC-DIFF diff <(sed 's/\/\/.*//' "Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/RpsTypes.cs" | tr -d ' \t\r\n') \ <(sed 's/\/\/.*//' "Server/games-src/RPS/RPS.Core/RpsTypes.cs" | tr -d ' \t\r\n') && echo TYPES-EQ || echo TYPES-DIFF ``` 去空白/注释后仍有差异则**人工逐处阅读 diff**:纯改写(`var`/合并语句/AllChoices 提取等)→ 以 `scripts~/Core` 为准继续;发现真语义差异 → **停下向用户报告**,勿擅自选边。 - [ ] **Step 5.2: 改 RPS.Core.csproj** 整文件替换为: ```xml netstandard2.1 9.0 disable RPS.Core RPS.Core false false ``` 删除 `Server/games-src/RPS/RPS.Core/RpsLogic.cs` 与 `RpsTypes.cs`。 - [ ] **Step 5.3: 改 RPS.Client.csproj**——`` 只留 Client 子目录,并加 RPS.Core 工程引用。将 ```xml ``` 替换为: ```xml ``` 并在 Framework.Shared 的 `` 中追加: ```xml false ``` - [ ] **Step 5.4: 构建验证 + 产物形状断言** ```bash cd "D:/UD/AI/AIC#Project" dotnet build Server/games-src/RPS/RPS.Server/RPS.Server.csproj -c Release -nologo 2>&1 | tail -3 dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo 2>&1 | tail -3 ls Server/games-src/RPS/RPS.Server/bin/Release/netstandard2.1/ | grep -E "RPS|Framework" ls Client/HotUpdateGames/RPS.Client/bin/Release/netstandard2.1/ | grep -E "RPS|Framework|Unity" ``` Expected: 两次构建 0 错误;server bin 有 `RPS.Core.dll RPS.Server.dll` **无** Framework.Shared.dll;client bin 有 `RPS.Client.dll` **无** RPS.Core.dll/Framework.Shared.dll/UnityEngine(Private=false 全部生效)。 - [ ] **Step 5.5: 刷新测试夹具 + 全量回归** ```bash cd "D:/UD/AI/AIC#Project" && bash Server/build-test-games.sh && dotnet test Server/Server.sln -nologo 2>&1 | tail -8 ``` Expected: `TestGames ready.`;0 失败。 --- ### Task 6: RPS 的 publish.json **Files:** - Create: `Client/Assets/MiniGames/RockPaperScissors/publish.json` - [ ] **Step 6.1: 写文件**(内容即模板,新游戏拷贝改字段): ```json { "gameId": "rps", "serverSrcDir": "RPS", "serverProject": "RPS.Server/RPS.Server.csproj", "serverAssembly": "RPS.Server.dll", "serverEntryType": "RPS.Server.RpsServerRoom", "clientProject": "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj", "coreDll": "RPS.Core.dll", "clientDll": "RPS.Client.dll", "clientEntryType": "RPS.Client.RpsGameClient", "minFrameworkVersion": 1, "playerCount": 2, "tickRateHz": 10 } ``` - [ ] **Step 6.2: 校验 JSON 语法** Run: `python -c "import json;json.load(open(r'D:/UD/AI/AIC#Project/Client/Assets/MiniGames/RockPaperScissors/publish.json',encoding='utf-8'));print('OK')"` Expected: `OK`(.meta 由 Unity 首开生成) --- ### Task 7: 客户端运行时改造(统一 AB 消费端) **Files:** - Create: `Client/Assets/Script/xmain/MiniGame/MiniGamePlatform.cs` - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs` - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs` - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs`(整文件替换) - Create: `Client/Assets/Script/xmain/MiniGame/MiniGameAssetLoader.cs` - Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs` 注意:`MiniGameManifest.cs` 被 `PublishTool.Tests.csproj` 链接编译——改动必须保持纯 C#(不引 UnityEngine),每步后跑 `dotnet test Server/PublishTool.Tests` 即时验证该文件。 - [ ] **Step 7.1: 新建 MiniGamePlatform.cs**: ```csharp using System.IO; using UnityEngine; namespace XGame.MiniGame { // 小游戏 CDN 的平台段(与 LoadDll.sPlatform / CDN 目录命名一致),及平台相关的本地 AB 打开方式 public static class MiniGamePlatform { #if UNITY_ANDROID public const string Name = "android"; #elif UNITY_IOS public const string Name = "ios"; #elif UNITY_WEBGL public const string Name = "webgl"; #else public const string Name = "pc"; #endif // WebGL 无同步文件系统 mmap,LoadFromFile 不可用 → 读字节走 LoadFromMemory;其余平台 LoadFromFile 更省内存 public static AssetBundle LoadAssetBundle(string localPath) { #if UNITY_WEBGL return AssetBundle.LoadFromMemory(File.ReadAllBytes(localPath)); #else return AssetBundle.LoadFromFile(localPath); #endif } } } ``` - [ ] **Step 7.2: MiniGameManifest 加 codeAb**——字段区(`ClientDll` 后)加: ```csharp public string CodeAb; // 代码 AB 文件名(统一 AB:Core/Client DLL 的 .bytes 都在里面) ``` `ParseGameJson` 的对象初始化里(`ClientDll = ...` 行后)加: ```csharp CodeAb = JsonMini.GetString(json, "codeAb"), ``` 校验区(`coreDll/clientDll` 检查后)加: ```csharp if (string.IsNullOrEmpty(m.CodeAb)) throw new FormatException("game.json 缺少 codeAb(统一 AB 新格式;旧 .bytes 直下包不再支持,请用 XWorld/生成小游戏更新 重新发布)"); ``` - [ ] **Step 7.3: 即时验证 Manifest 仍纯 C# 可编译** Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5` Expected: 编译通过、全 PASS(跨组件 digest 一致性测试不受 codeAb 影响) - [ ] **Step 7.4: MiniGameDownloader 加平台段**——`RemoteBase` 属性改为: ```csharp private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/"; ``` (`LocalDir` 不变:设备平台固定,无需平台段。) - [ ] **Step 7.5: MiniGameAssemblyLoader 整文件替换**: ```csharp using System; using System.IO; using System.Reflection; using UnityEngine; using XWorld.Framework; namespace XGame.MiniGame { // 从代码 AB(manifest.CodeAb)取出 Core/Client 热更 DLL 字节并加载,反射出 IGameClient 入口工厂 public sealed class MiniGameAssemblyLoader { public Func CreateClient { get; private set; } public string Error { get; private set; } // localDir: MiniGameDownloader.LocalDir;manifest: 解析好的 game.json public bool Load(string localDir, MiniGameManifest manifest) { string abPath = localDir + manifest.CodeAb; if (!File.Exists(abPath)) { Error = $"缺少代码 AB: {abPath}(需要 codeAb 统一 AB 格式,旧 .bytes 包不再支持)"; return false; } AssetBundle ab = null; try { ab = MiniGamePlatform.LoadAssetBundle(abPath); if (ab == null) { Error = "代码 AB 打开失败: " + abPath; return false; } byte[] coreBytes = LoadDllBytes(ab, manifest.CoreDll); byte[] clientBytes = LoadDllBytes(ab, manifest.ClientDll); if (coreBytes == null || clientBytes == null) { Error = $"代码 AB 内缺少 {manifest.CoreDll}/{manifest.ClientDll} 的 .bytes(AB 内资产: {string.Join(", ", ab.GetAllAssetNames())})"; return false; } // Core 先于 Client 加载(Client 依赖 Core) Assembly.Load(coreBytes); Assembly clientAsm = Assembly.Load(clientBytes); Type entry = clientAsm.GetType(manifest.ClientEntryType); if (entry == null) { Error = "找不到入口类型 " + manifest.ClientEntryType; return false; } if (!typeof(IGameClient).IsAssignableFrom(entry)) { Error = manifest.ClientEntryType + " 未实现 IGameClient"; return false; } CreateClient = () => (IGameClient)Activator.CreateInstance(entry); return true; } catch (Exception e) { Error = "加载小游戏程序集失败: " + e; Debug.LogError("[MiniGameAssemblyLoader] " + Error); return false; } finally { // DLL 字节已复制进托管内存,AB 本体可卸;false=不销毁已取出的对象 if (ab != null) ab.Unload(false); } } // "X.dll.bytes" 导入后资产名为 "X.dll"(Unity 剥最后一个扩展名);两种写法都试 private static byte[] LoadDllBytes(AssetBundle ab, string dllName) { TextAsset ta = ab.LoadAsset(dllName) ?? ab.LoadAsset(dllName + ".bytes"); return ta != null ? ta.bytes : null; } } } ``` - [ ] **Step 7.6: 新建 MiniGameAssetLoader.cs**: ```csharp using System; using System.Collections.Generic; using System.IO; using UnityEngine; using XWorld.Framework; namespace XGame.MiniGame { // IAssetLoader over 已下载的小游戏资源 AB(manifest.Assets);首次 Load 时开全部包并缓存,退出统一卸载 public sealed class MiniGameAssetLoader : IAssetLoader { private readonly string _localDir; private readonly List _abNames; private readonly List _opened = new List(); private bool _openedAll; public MiniGameAssetLoader(string localDir, MiniGameManifest manifest) { _localDir = localDir; _abNames = manifest.Assets; } // path: 打包时的资产路径(如 "Assets/MiniGames/RockPaperScissors/res/UI/Prefab/UI_RockPaperScissors.prefab") // 或裸资产名("UI_RockPaperScissors");找不到回调 null。 public void Load(string path, Action onLoaded) { EnsureOpened(); foreach (var ab in _opened) { UnityEngine.Object obj = ab.LoadAsset(path); if (obj == null) obj = ab.LoadAsset(Path.GetFileNameWithoutExtension(path)); if (obj != null) { onLoaded?.Invoke(obj); return; } } Debug.LogError("[MiniGameAssetLoader] 资产未找到: " + path); onLoaded?.Invoke(null); } // 小游戏退出时由 MiniGameHost 调用;true=连已实例化引用的资产一起卸 public void UnloadAll() { foreach (var ab in _opened) if (ab != null) ab.Unload(true); _opened.Clear(); _openedAll = false; } private void EnsureOpened() { if (_openedAll) return; _openedAll = true; foreach (var name in _abNames) { string p = _localDir + name; if (!File.Exists(p)) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 缺失: " + p); continue; } var ab = MiniGamePlatform.LoadAssetBundle(p); if (ab == null) { Debug.LogError("[MiniGameAssetLoader] 资源 AB 打开失败: " + p); continue; } _opened.Add(ab); } } } } ``` - [ ] **Step 7.7: MiniGameHost 接线**——三处: ① 字段区(`private GameClientCtx _ctx;` 后)加: ```csharp private MiniGameAssetLoader _assets; ``` ② `CoEnter` 中构造 ctx 的代码,将 ```csharp _ctx = new GameClientCtx( new UnityAssetLoader(), ``` 改为: ```csharp _assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest); _ctx = new GameClientCtx( _assets, ``` ③ `ExitGame` 中,将 ```csharp _net = null; _ctx = null; // 卸载本小游戏 AB(XResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项 ``` 改为: ```csharp _net = null; _ctx = null; if (_assets != null) { _assets.UnloadAll(); _assets = null; } // 卸载本小游戏资源 AB ``` (`UnityAssetLoader.cs` 保留不删——大厅管线仍可能用;本任务只换 minigame 注入。) - [ ] **Step 7.8: Checkpoint** Run: `dotnet test Server/PublishTool.Tests -nologo 2>&1 | tail -5` Expected: 全 PASS。其余文件的编译验证在 Task 8。 --- ### Task 8: MiniGameRuntime.Verify(运行时改动的 dotnet 编译验证壳) **Files:** - Create: `Client/HotUpdateGames/MiniGameRuntime.Verify/MiniGameRuntime.Verify.csproj` 原理:把 `Assets/Script/xmain/MiniGame/**` 源码 + `Library/ScriptAssemblies` 里 Unity 已编好的 `XWorld.Link.dll`(提供 XResLoader/XWCoroutine/GlobalData/XWebSocket 等类型)一起交给 dotnet csc。源码类型与 DLL 内同名旧类型冲突是预期的 → `NoWarn CS0436`(源码优先)。**不进 Server.sln、不发布,仅验证用。** - [ ] **Step 8.1: 写 csproj**: ```xml netstandard2.1 9.0 disable disable false MiniGameRuntime.Verify $(NoWarn);CS0436 D:\Program Files\Unity 2022.3.62f2c1\Editor ..\..\Library\ScriptAssemblies $(UnityEditorPath)\Data\Managed\UnityEngine $(UnityManaged)\UnityEngine.dllfalse $(UnityManaged)\UnityEngine.CoreModule.dllfalse $(UnityManaged)\UnityEngine.IMGUIModule.dllfalse $(UnityManaged)\UnityEngine.UnityWebRequestModule.dllfalse $(UnityManaged)\UnityEngine.AssetBundleModule.dllfalse $(UnityManaged)\UnityEngine.UIModule.dllfalse $(UnityManaged)\UnityEngine.TextRenderingModule.dllfalse $(ScriptAssemblies)\UnityEngine.UI.dllfalse $(ScriptAssemblies)\XWorld.Link.dllfalse $(ScriptAssemblies)\XWorld.Framework.Shared.dllfalse ``` - [ ] **Step 8.2: 构建验证** Run: `cd "D:/UD/AI/AIC#Project" && dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify -c Release -nologo 2>&1 | tail -5` Expected: `0 个错误`。报缺 UnityEngine 模块类型 → 按报错在 csproj 补对应 `UnityEngine.*Module.dll` 引用后重跑;报缺项目内类型(如 CSharpClientApp)→ 确认 `$(ScriptAssemblies)\XWorld.Link.dll` 存在(须用户先在 Unity 里成功编译过一次工程)。 --- ### Task 9: Editor 菜单 + 流水线 **Files:** - Modify: `Client/Assets/Editor/MiniGamePublishMenu.cs`(整文件替换,旧 stub 菜单「XWorld/生成小游戏」随之消失) - Create: `Client/Assets/Editor/MiniGamePublishPipeline.cs` Editor 代码本环境无法编译——写完后**逐行自查**(引用的每个 API 均为 Unity 2022.3 标准 API),最终由用户在 Unity 中核对。 - [ ] **Step 9.1: MiniGamePublishMenu.cs 整文件替换**: ```csharp #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace XGame.Editor { // publish.json 视图(JsonUtility:C# 字段名须与 json 键一致,故小写开头) [Serializable] public sealed class MiniGamePublishSpec { public string gameId; public string serverSrcDir; // 相对 Server/games-src/ public string serverProject; // 相对 serverSrcDir public string serverAssembly; public string serverEntryType; public string clientProject; // 相对仓库根 public string coreDll; public string clientDll; public string clientEntryType; public int minFrameworkVersion = 1; public int playerCount = 2; public int tickRateHz = 10; } public static class MiniGamePublishMenu { [MenuItem("XWorld/生成小游戏更新", false, 802)] public static void Open() => MiniGamePublishWindow.Open(); } public sealed class MiniGamePublishWindow : EditorWindow { private string[] _gameDirs = Array.Empty(); private string[] _gameNames = Array.Empty(); private int _selected; private int _version = 1; private bool _pc, _android, _ios, _webgl; private Vector2 _scroll; public static void Open() { var w = GetWindow("生成小游戏更新"); w.minSize = new Vector2(360, 320); w.RefreshGames(); w.Show(); } private void RefreshGames() { string root = Path.Combine(Application.dataPath, "MiniGames"); _gameDirs = Directory.Exists(root) ? Directory.GetDirectories(root).Where(d => File.Exists(Path.Combine(d, "publish.json"))).ToArray() : Array.Empty(); _gameNames = _gameDirs.Select(Path.GetFileName).ToArray(); _selected = Mathf.Clamp(_selected, 0, Mathf.Max(0, _gameDirs.Length - 1)); // 默认平台 = 当前激活 BuildTarget _pc = _android = _ios = _webgl = false; switch (EditorUserBuildSettings.activeBuildTarget) { case BuildTarget.Android: _android = true; break; case BuildTarget.iOS: _ios = true; break; case BuildTarget.WebGL: _webgl = true; break; default: _pc = true; break; } RefreshDefaultVersion(); } private void RefreshDefaultVersion() { if (_gameDirs.Length == 0) return; var spec = LoadSpec(_gameDirs[_selected]); _version = (spec == null || string.IsNullOrEmpty(spec.gameId)) ? 1 : MiniGamePublishPipeline.NextVersion(spec.gameId); } private static MiniGamePublishSpec LoadSpec(string gameDir) { try { return JsonUtility.FromJson(File.ReadAllText(Path.Combine(gameDir, "publish.json"))); } catch (Exception e) { Debug.LogError("[MiniGamePublish] publish.json 解析失败: " + e.Message); return null; } } private void OnGUI() { if (_gameDirs.Length == 0) { EditorGUILayout.HelpBox("Assets/MiniGames 下没有含 publish.json 的小游戏目录。", MessageType.Warning); if (GUILayout.Button("刷新")) RefreshGames(); return; } _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.LabelField("小游戏", EditorStyles.boldLabel); int sel = GUILayout.SelectionGrid(_selected, _gameNames, 1, EditorStyles.radioButton); if (sel != _selected) { _selected = sel; RefreshDefaultVersion(); } EditorGUILayout.Space(); _version = Mathf.Max(1, EditorGUILayout.IntField("版本号", _version)); EditorGUILayout.Space(); EditorGUILayout.LabelField("平台(AB 按平台构建)", EditorStyles.boldLabel); _pc = EditorGUILayout.ToggleLeft("PC (StandaloneWindows64)", _pc); _android = EditorGUILayout.ToggleLeft("Android", _android); _ios = EditorGUILayout.ToggleLeft("iOS", _ios); _webgl = EditorGUILayout.ToggleLeft("WebGL", _webgl); EditorGUILayout.EndScrollView(); EditorGUILayout.Space(); using (new EditorGUI.DisabledScope(!(_pc || _android || _ios || _webgl))) { if (GUILayout.Button("生成", GUILayout.Height(32))) { var spec = LoadSpec(_gameDirs[_selected]); if (spec == null || string.IsNullOrEmpty(spec.gameId)) { EditorUtility.DisplayDialog("生成小游戏更新", "publish.json 无效(缺 gameId)", "OK"); } else { var targets = new List(); if (_pc) targets.Add(new MiniGamePublishPipeline.PlatformChoice("pc", BuildTarget.StandaloneWindows64)); if (_android) targets.Add(new MiniGamePublishPipeline.PlatformChoice("android", BuildTarget.Android)); if (_ios) targets.Add(new MiniGamePublishPipeline.PlatformChoice("ios", BuildTarget.iOS)); if (_webgl) targets.Add(new MiniGamePublishPipeline.PlatformChoice("webgl", BuildTarget.WebGL)); MiniGamePublishPipeline.Run(spec, _gameDirs[_selected], _version, targets); } } } } } } #endif ``` - [ ] **Step 9.2: 新建 MiniGamePublishPipeline.cs**: ```csharp #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; namespace XGame.Editor { // 生成小游戏更新的流水线:dotnet build 两端 DLL → 打代码/资源 AB → PublishTool.Cli 落位。 // 产物:CDN/minigame////(客户端)、deploy/minigame///(服务器)。 public static class MiniGamePublishPipeline { public struct PlatformChoice { public string Name; public BuildTarget Target; public PlatformChoice(string name, BuildTarget target) { Name = name; Target = target; } } // resolved-spec.json(交给 PublishTool.Cli;字段小写对齐 GameSpecJson 大小写不敏感解析) [Serializable] private sealed class ResolvedSpecJson { 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 string codeAb; public List assets; } private const string CodeAbName = "code.unity3d"; private const string ResAbName = "res.unity3d"; // staging 不能用 '~' 隐藏目录:隐藏资产不入 AssetDatabase,进不了 AB private const string StagingAssetDir = "Assets/MiniGameStaging"; private const string CliProject = "Server/PublishTool.Cli/PublishTool.Cli.csproj"; private const string CliDll = "Server/PublishTool.Cli/bin/Release/net10.0/XWorld.PublishTool.Cli.dll"; private const string PrivateKeyRel = "Tools/keys/minigame-private.pem"; private static string RepoRoot => Path.GetFullPath(Path.Combine(Application.dataPath, "../..")); // 默认版本号 = CDN/minigame// 下已有数字目录 max+1 public static int NextVersion(string gameId) { string dir = Path.Combine(RepoRoot, "CDN/minigame", gameId); int max = 0; if (Directory.Exists(dir)) foreach (var d in Directory.GetDirectories(dir)) if (int.TryParse(Path.GetFileName(d), out int v) && v > max) max = v; return max + 1; } public static void Run(MiniGamePublishSpec spec, string gameDir, int version, List platforms) { string cdnVerDir = Path.Combine(RepoRoot, "CDN/minigame", spec.gameId, version.ToString()); string deployVerDir = Path.Combine(RepoRoot, "deploy/minigame", spec.gameId, version.ToString()); if ((Directory.Exists(cdnVerDir) || Directory.Exists(deployVerDir)) && !EditorUtility.DisplayDialog("生成小游戏更新", $"版本 {spec.gameId}/{version} 已存在,覆盖重发?\n{cdnVerDir}\n{deployVerDir}", "覆盖", "取消")) return; string tmp = Path.Combine(RepoRoot, "Temp/MiniGamePublish", spec.gameId); string keyPath = Path.Combine(RepoRoot, PrivateKeyRel); bool signed = File.Exists(keyPath); try { if (Directory.Exists(tmp)) Directory.Delete(tmp, true); Directory.CreateDirectory(tmp); // 1) 服务器 DLL Progress("dotnet build (server)", 0.05f); string serverProj = Path.Combine(RepoRoot, "Server/games-src", spec.serverSrcDir, spec.serverProject); RunOrThrow("dotnet build (server)", "dotnet", $"build \"{serverProj}\" -c Release -nologo"); string serverBin = Path.Combine(Path.GetDirectoryName(serverProj), "bin/Release/netstandard2.1"); RequireFile(Path.Combine(serverBin, spec.serverAssembly)); RequireFile(Path.Combine(serverBin, spec.coreDll)); // 2) 客户端 DLL(Core 取服务器构建产物——两端同一 IL) Progress("dotnet build (client)", 0.15f); string clientProj = Path.Combine(RepoRoot, spec.clientProject); RunOrThrow("dotnet build (client)", "dotnet", $"build \"{clientProj}\" -c Release -nologo"); string clientBin = Path.Combine(Path.GetDirectoryName(clientProj), "bin/Release/netstandard2.1"); RequireFile(Path.Combine(clientBin, spec.clientDll)); // 3) DLL → Assets staging(.bytes 才能作为 TextAsset 进 AB) Progress("导入 DLL 为 TextAsset", 0.25f); string stagingFull = Path.Combine(Application.dataPath, "MiniGameStaging/code"); Directory.CreateDirectory(stagingFull); File.Copy(Path.Combine(serverBin, spec.coreDll), Path.Combine(stagingFull, spec.coreDll + ".bytes"), true); File.Copy(Path.Combine(clientBin, spec.clientDll), Path.Combine(stagingFull, spec.clientDll + ".bytes"), true); AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); string[] codeAssets = { $"{StagingAssetDir}/code/{spec.coreDll}.bytes", $"{StagingAssetDir}/code/{spec.clientDll}.bytes", }; // 4) 每平台打 AB string[] resAssets = CollectResAssets(Path.GetFileName(gameDir)); var builds = new List { new AssetBundleBuild { assetBundleName = CodeAbName, assetNames = codeAssets }, }; if (resAssets.Length > 0) builds.Add(new AssetBundleBuild { assetBundleName = ResAbName, assetNames = resAssets }); else Debug.LogWarning($"[MiniGamePublish] {gameDir}/res 为空,跳过资源 AB(仅发代码 AB)"); foreach (var pf in platforms) { Progress($"BuildAssetBundles ({pf.Name})", 0.35f); string abOut = Path.Combine(tmp, "ab", pf.Name); Directory.CreateDirectory(abOut); var manifest = BuildPipeline.BuildAssetBundles( abOut, builds.ToArray(), BuildAssetBundleOptions.ChunkBasedCompression, pf.Target); if (manifest == null) throw new Exception($"BuildAssetBundles 失败: {pf.Name}"); RequireFile(Path.Combine(abOut, CodeAbName)); } // 5) resolved-spec.json var resolved = new ResolvedSpecJson { gameId = spec.gameId, version = version, minFrameworkVersion = spec.minFrameworkVersion, playerCount = spec.playerCount, tickRateHz = spec.tickRateHz, serverAssembly = spec.serverAssembly, serverEntryType = spec.serverEntryType, coreDll = spec.coreDll, clientDll = spec.clientDll, clientEntryType = spec.clientEntryType, codeAb = CodeAbName, assets = resAssets.Length > 0 ? new List { ResAbName } : new List(), }; string specPath = Path.Combine(tmp, "resolved-spec.json"); File.WriteAllText(specPath, JsonUtility.ToJson(resolved, true), new UTF8Encoding(false)); // 6) PublishTool.Cli 落位(先临时目录,成功后整体 Move,防 Cdn.Runner 提供半成品) Progress("dotnet build (PublishTool.Cli)", 0.55f); RunOrThrow("dotnet build (PublishTool.Cli)", "dotnet", $"build \"{Path.Combine(RepoRoot, CliProject)}\" -c Release -nologo"); string cliDll = Path.Combine(RepoRoot, CliDll); RequireFile(cliDll); string keyArg = signed ? $" --key \"{keyPath}\"" : ""; // 6a) 服务器:只汇集需要的两个 DLL 作为 src Progress("发布 server", 0.65f); string serverSrc = Path.Combine(tmp, "server-src"); Directory.CreateDirectory(serverSrc); File.Copy(Path.Combine(serverBin, spec.serverAssembly), Path.Combine(serverSrc, spec.serverAssembly), true); File.Copy(Path.Combine(serverBin, spec.coreDll), Path.Combine(serverSrc, spec.coreDll), true); string outServer = Path.Combine(tmp, "out-server"); RunOrThrow("PublishTool.Cli (server)", "dotnet", $"\"{cliDll}\" --mode server --src \"{serverSrc}\" --out \"{outServer}\" --spec \"{specPath}\"{keyArg}"); // 6b) 客户端:每平台一次,src = 该平台 AB 输出目录 var outClients = new List<(string name, string dir)>(); foreach (var pf in platforms) { Progress($"发布 client ({pf.Name})", 0.75f); string outClient = Path.Combine(tmp, "out-client-" + pf.Name); RunOrThrow($"PublishTool.Cli (client {pf.Name})", "dotnet", $"\"{cliDll}\" --mode client --src \"{Path.Combine(tmp, "ab", pf.Name)}\" --out \"{outClient}\" --spec \"{specPath}\"{keyArg}"); outClients.Add((pf.Name, outClient)); } // 7) 原子落位 Progress("落位 CDN/deploy", 0.9f); MoveIntoPlace(outServer, deployVerDir); foreach (var (name, dir) in outClients) MoveIntoPlace(dir, Path.Combine(cdnVerDir, name)); var sb = new StringBuilder(); sb.AppendLine($"生成完成:{spec.gameId} v{version}" + (signed ? "(已签名)" : "(未签名:无 " + PrivateKeyRel + ")")); sb.AppendLine("服务器: " + deployVerDir); foreach (var (name, _) in outClients) sb.AppendLine($"客户端[{name}]: " + Path.Combine(cdnVerDir, name)); Debug.Log("[MiniGamePublish] " + sb); EditorUtility.DisplayDialog("生成小游戏更新", sb.ToString(), "OK"); } catch (Exception e) { Debug.LogError("[MiniGamePublish] 失败: " + e); EditorUtility.DisplayDialog("生成小游戏更新失败", e.Message, "OK"); } finally { EditorUtility.ClearProgressBar(); CleanupStaging(); try { if (Directory.Exists(tmp)) Directory.Delete(tmp, true); } catch { /* 占用时留给下次 Run 清 */ } } } private static void Progress(string info, float p) => EditorUtility.DisplayProgressBar("生成小游戏更新", info, p); private static string[] CollectResAssets(string gameDirName) { string resPath = $"Assets/MiniGames/{gameDirName}/res"; if (!AssetDatabase.IsValidFolder(resPath)) return Array.Empty(); return AssetDatabase.FindAssets("", new[] { resPath }) .Select(AssetDatabase.GUIDToAssetPath) .Distinct() .Where(p => !AssetDatabase.IsValidFolder(p)) .ToArray(); } private static void MoveIntoPlace(string src, string dst) { Directory.CreateDirectory(Path.GetDirectoryName(dst)); if (Directory.Exists(dst)) Directory.Delete(dst, true); Directory.Move(src, dst); } private static void RequireFile(string p) { if (!File.Exists(p)) throw new Exception("缺少构建产物: " + p); } private static void CleanupStaging() { string full = Path.Combine(Application.dataPath, "MiniGameStaging"); bool dirty = false; if (Directory.Exists(full)) { Directory.Delete(full, true); dirty = true; } string meta = full + ".meta"; if (File.Exists(meta)) { File.Delete(meta); dirty = true; } if (dirty) AssetDatabase.Refresh(); } // 起进程并收敛 stdout/stderr(事件式读取避免缓冲区互锁);非 0 退出码抛异常带输出尾部 private static string RunOrThrow(string step, string file, string args) { var psi = new System.Diagnostics.ProcessStartInfo { FileName = file, Arguments = args, WorkingDirectory = RepoRoot, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; var sb = new StringBuilder(); using (var p = new System.Diagnostics.Process { StartInfo = psi }) { p.OutputDataReceived += (_, e) => { if (e.Data != null) lock (sb) sb.AppendLine(e.Data); }; p.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (sb) sb.AppendLine(e.Data); }; p.Start(); p.BeginOutputReadLine(); p.BeginErrorReadLine(); p.WaitForExit(); string output = sb.ToString(); if (p.ExitCode != 0) { string tail = output.Length > 4000 ? output.Substring(output.Length - 4000) : output; throw new Exception($"{step} 失败 (exit {p.ExitCode}):\n{tail}"); } return output; } } } } #endif ``` - [ ] **Step 9.3: 自查清单**(无 Unity,逐项人工核对代码) - 菜单名恰为 `XWorld/生成小游戏更新`;旧 stub `XWorld/生成小游戏` 已随整文件替换消失。 - `BuildPipeline.BuildAssetBundles(string, AssetBundleBuild[], BuildAssetBundleOptions, BuildTarget)` 四参重载 ✓(Unity 2022.3 存在)。 - AB 名 `code.unity3d`/`res.unity3d` 与 resolved-spec 的 `codeAb`/`assets` 一致;与 Task 3 `PublishClient` 消费一致。 - staging 目录无 `~` 后缀;finally 里删目录+.meta+Refresh。 - 所有路径 `Path.Combine(RepoRoot, ...)`,进程 WorkingDirectory=RepoRoot。 --- ### Task 10: 配套调整(本地网关指向 deploy/minigame + 废弃 smoke ps1) **Files:** - Modify: `Client/Assets/Script/Editor/XWorldUtil.cs:26`(常量)与 `:585-589`(提示文案) - Delete: `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1` - Modify: `Tools/PcMiniGameSmoke/README.md` - [ ] **Step 10.1: XWorldUtil 常量改指 deploy/minigame**(路径相对 `Application.dataPath`,`../../` = 仓库根): ```csharp private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame"; ``` - [ ] **Step 10.2: 改缺目录提示**(`StartMiniGameGatewayRunnerProcess` 内): ```csharp EditorUtility.DisplayDialog("XWorld Server", "PC minigame package not found:\n" + gamesRoot + "\n\nRun Unity menu [XWorld/生成小游戏更新] first.", "OK"); ``` - [ ] **Step 10.3: 删除 ps1 并重写 README** 删除 `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`。`Tools/PcMiniGameSmoke/README.md` 重写为(要点):旧 ps1 已废弃(裸 .bytes 直下格式运行时不再支持,且 ps1 打不了 AB);本地打包/联调改为 Unity 菜单 `XWorld/生成小游戏更新`(客户端产物 → `CDN/minigame/`,服务器产物 → `deploy/minigame/`,Play 自动起的本地网关 `--gamesRoot` 已指向后者);`XWorld/PC Smoke/...` 两个 Launcher 菜单仍可用。 - [ ] **Step 10.4: 全仓引用清扫** Run: `grep -rn "Build-PcMiniGameSmoke\|PcMiniGameSmoke/server" "D:/UD/AI/AIC#Project" --include="*.cs" --include="*.md" --include="*.ps1" | grep -v Library | grep -v docs/superpowers` Expected: 无残留(有则逐处改为指向新菜单/新路径)。 --- ### Task 11: 终验 + 人工核对清单 - [ ] **Step 11.1: .NET 全量回归** ```bash cd "D:/UD/AI/AIC#Project" dotnet test Server/Server.sln -nologo 2>&1 | tail -8 dotnet build Server/PublishTool.Cli -c Release -nologo 2>&1 | tail -3 dotnet build Server/games-src/RPS/RPS.Server/RPS.Server.csproj -c Release -nologo 2>&1 | tail -3 dotnet build Client/HotUpdateGames/RPS.Client/RPS.Client.csproj -c Release -nologo 2>&1 | tail -3 dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify -c Release -nologo 2>&1 | tail -3 ``` Expected: 测试 0 失败;4 个构建全部 0 错误。 - [ ] **Step 11.2: 模拟 Editor 产物的 CLI 端到端**(bash 造假 AB 文件走一遍双端布局,校验 files.txt/md5 命名/game.json codeAb——同 Task 4 Step 4.7 但按 `` 目录形状落到临时 CDN/deploy 树后 `find` 打印核对)。 - [ ] **Step 11.3: 向用户提交人工核对清单**(本环境无 Unity,以下必须在 Unity/真机核对): 1. Unity 打开工程:新 .cs 编译通过、生成 .meta;菜单出现 `XWorld/生成小游戏更新`。 2. 菜单选 RPS、版本默认 1、勾 PC → 生成:核对 `CDN/minigame/rps/1/pc/`(game.json 含 codeAb、files.txt、code_/res_ md5 命名 AB、空 sig)与 `deploy/minigame/rps/1/`(两 DLL + game.json + files.txt + sig)。 3. Play(bLocal):本地网关自动起且 `--gamesRoot` 指向 `deploy/minigame`;进 RPS 对局——验证 Downloader 平台段 URL、代码 AB 取 DLL、Assembly.Load、对局可玩、退出时资源 AB 卸载无报错。 4. 勾 Android 再生成:确认 BuildAssetBundles 不切激活平台可产 Android AB;真机拉 `CDN/minigame/rps//android/` 走通(WebGL 的 LoadFromMemory 路径与微信 WASM 合规仍是已知开放风险)。 5. 上线前:生成 RSA 密钥对,私钥放 `Tools/keys/minigame-private.pem`,公钥填 `ClientManifestVerifier` 与网关 `GameServerHost.StartAsync(publicKeyPem)`,重发并验证验签链路。 --- ## Self-Review 记录 - **Spec 覆盖**:§1 布局(Task 3/6/9)、§1.4 Core 单源(Task 5)、§2 编排(Task 4/9)、§3 运行时(Task 7)、§4.3 配套(Task 10)、§4.2 验证(Task 8/11)✓ - **占位符**:无 TBD/TODO;每个代码步骤给出完整代码 ✓ - **类型一致性**:`GameSpec.CodeAb`(Task 1)↔ `GameSpecJson`(Task 2)↔ `PublishClient`(Task 3)↔ CLI(Task 4)↔ `ResolvedSpecJson.codeAb`(Task 9)↔ `MiniGameManifest.CodeAb`(Task 7);AB 名 `code.unity3d`/`res.unity3d` 全链一致;`PublishServer/PublishClient` 签名在 Task 3/4/9 一致 ✓ - **无 git**:所有 commit 步骤已替换为验证 checkpoint ✓