Files
AIC-Project/docs/superpowers/plans/2026-07-07-minigame-publish-pipeline.md
T
2026-07-10 10:24:29 +08:00

1524 lines
68 KiB
Markdown
Raw 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.
# 小游戏打包流程(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/<id>/<ver>/<platform>/`,编服务器 DLL 到 `deploy/minigame/<id>/<ver>/`
**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 APIEditorWindow/BuildPipeline)、.NET 10PublishTool + 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 级编译验证 + 文末人工核对清单。
- 仓库根记为 `<repo>` = `D:\UD\AI\AIC#Project`。所有 dotnet 命令在 `<repo>` 下执行。
- 现有基线:`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<string> { "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 文件名(统一 ABDLL .bytes 打进它)
public List<string> Assets = new List<string>(); // 资源 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: PASS2 tests)。注意 `PublishLayoutTests` 此时仍引用旧布局——它在 Task 3 重写;若本步全项目编译失败即是它,可暂时容忍 filter 级通过、Task 3 收口。
---
### Task 2: GameSpecJsonresolved-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.jsonEditor 侧 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<GameSpec>(json, Opts);
if (s.Assets == null) s.Assets = new System.Collections.Generic.List<string>();
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: PASS2 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<string> { "res.unity3d" },
};
private static string TempDir() =>
Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "pub_" + System.Guid.NewGuid().ToString("N"))).FullName;
[Fact]
public void PublishServer_ProducesDlls_GameJson_Manifest_Signature()
{
string src = TempDir(); string outDir = Path.Combine(TempDir(), "s");
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1, 2, 3 });
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 4, 5 });
using var rsa = RSA.Create(2048);
new PublishLayout().PublishServer(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Server.dll")));
Assert.True(File.Exists(Path.Combine(outDir, "RPS.Core.dll")));
Assert.True(File.Exists(Path.Combine(outDir, "game.json")));
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
Assert.True(fm.Entries.ContainsKey("RPS.Server.dll"));
Assert.True(fm.Entries.ContainsKey("RPS.Core.dll"));
Assert.True(fm.Entries.ContainsKey("game.json"));
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
}
[Fact]
public void PublishClient_Md5NamedAbs_CodeAbInManifest_NoLooseBytes()
{
string src = TempDir(); string outDir = Path.Combine(TempDir(), "c");
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 6, 7, 8 });
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 9 });
using var rsa = RSA.Create(2048);
new PublishLayout().PublishClient(src, outDir, MakeSpec(), rsa.ExportRSAPrivateKeyPem());
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(outDir, "files.txt")));
// files.txt 登记逻辑名;落盘 md5 命名(与 MiniGameDownloader.Md5Name 一致)
Assert.True(fm.Entries.ContainsKey("code.unity3d"));
Assert.True(fm.Entries.ContainsKey("res.unity3d"));
string codeMd5 = Md5Util.OfFile(Path.Combine(src, "code.unity3d"));
string resMd5 = Md5Util.OfFile(Path.Combine(src, "res.unity3d"));
Assert.True(File.Exists(Path.Combine(outDir, $"code_{codeMd5}.unity3d")));
Assert.True(File.Exists(Path.Combine(outDir, $"res_{resMd5}.unity3d")));
// 统一 AB:不再有散 .bytes 文件
Assert.Empty(Directory.GetFiles(outDir, "*.bytes"));
// game.json 带 codeAb
Assert.Contains("\"codeAb\": \"code.unity3d\"", File.ReadAllText(Path.Combine(outDir, "game.json")));
byte[] sig = File.ReadAllBytes(Path.Combine(outDir, "files.txt.sig"));
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
}
[Fact]
public void NullKey_WritesEmptySig_BothTrees()
{
string src = TempDir();
string outS = Path.Combine(TempDir(), "s"); string outC = Path.Combine(TempDir(), "c");
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1 });
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 2 });
File.WriteAllBytes(Path.Combine(src, "code.unity3d"), new byte[] { 3 });
File.WriteAllBytes(Path.Combine(src, "res.unity3d"), new byte[] { 4 });
var layout = new PublishLayout();
layout.PublishServer(src, outS, MakeSpec(), null);
layout.PublishClient(src, outC, MakeSpec(), null);
Assert.Equal(0, new FileInfo(Path.Combine(outS, "files.txt.sig")).Length);
Assert.Equal(0, new FileInfo(Path.Combine(outC, "files.txt.sig")).Length);
}
[Fact]
public void PublishClient_EmptyCodeAb_Throws()
{
var spec = MakeSpec(); spec.CodeAb = null;
Assert.Throws<System.InvalidOperationException>(
() => new PublishLayout().PublishClient(TempDir(), Path.Combine(TempDir(), "c"), spec, null));
}
}
}
```
- [ ] **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 即 <gamesRoot>/<id>/<ver>/ 的内容(普通文件名,不做 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/<id>/<ver>/<platform>/ 的内容。
// 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<byte>()
: 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<System.ArgumentException>(() => 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 <dir> --out <dir> --spec <json> [--key <pem>]
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 <dir> --out <dir> --spec <resolved-spec.json> [--key <private.pem>]";
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: PASS5 个用例)
- [ ] **Step 4.5: 建 Cli 工程** `Server/PublishTool.Cli/PublishTool.Cli.csproj`
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyName>XWorld.PublishTool.Cli</AssemblyName>
<RootNamespace>XWorld.PublishTool.Cli</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
</ItemGroup>
</Project>
```
`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_<md5>.unity3d res_<md5>.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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>RPS.Core</AssemblyName>
<RootNamespace>RPS.Core</RootNamespace>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<!-- Core 唯一源码位:客户端 scripts~/Core'~' 目录 Unity 不编译;服务端/客户端两端共用同一批 .cs) -->
<Compile Include="..\..\..\..\Client\Assets\MiniGames\RockPaperScissors\scripts~\Core\**\*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
</Project>
```
删除 `Server/games-src/RPS/RPS.Core/RpsLogic.cs``RpsTypes.cs`
- [ ] **Step 5.3: 改 RPS.Client.csproj**——`<Compile>` 只留 Client 子目录,并加 RPS.Core 工程引用。将
```xml
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\**\*.cs" />
```
替换为:
```xml
<Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\Client\**\*.cs" />
```
并在 Framework.Shared 的 `<ItemGroup>` 中追加:
```xml
<ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
<Private>false</Private>
</ProjectReference>
```
- [ ] **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.dllclient bin 有 `RPS.Client.dll` **无** RPS.Core.dll/Framework.Shared.dll/UnityEnginePrivate=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 无同步文件系统 mmapLoadFromFile 不可用 → 读字节走 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 文件名(统一 ABCore/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
{
// 从代码 ABmanifest.CodeAb)取出 Core/Client 热更 DLL 字节并加载,反射出 IGameClient 入口工厂
public sealed class MiniGameAssemblyLoader
{
public Func<IGameClient> CreateClient { get; private set; }
public string Error { get; private set; }
// localDir: MiniGameDownloader.LocalDirmanifest: 解析好的 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} 的 .bytesAB 内资产: {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<TextAsset>(dllName) ?? ab.LoadAsset<TextAsset>(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 已下载的小游戏资源 ABmanifest.Assets);首次 Load 时开全部包并缓存,退出统一卸载
public sealed class MiniGameAssetLoader : IAssetLoader
{
private readonly string _localDir;
private readonly List<string> _abNames;
private readonly List<AssetBundle> _opened = new List<AssetBundle>();
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<object> 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;
// 卸载本小游戏 ABXResLoader 按 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<AssemblyName>MiniGameRuntime.Verify</AssemblyName>
<!-- CS0436: 源码类型与 XWorld.Link.dll 内旧版本同名——验证壳的预期冲突,源码优先 -->
<NoWarn>$(NoWarn);CS0436</NoWarn>
<UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f2c1\Editor</UnityEditorPath>
<ScriptAssemblies>..\..\Library\ScriptAssemblies</ScriptAssemblies>
<UnityManaged>$(UnityEditorPath)\Data\Managed\UnityEngine</UnityManaged>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\Assets\Script\xmain\MiniGame\**\*.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="UnityEngine"><HintPath>$(UnityManaged)\UnityEngine.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(UnityManaged)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.IMGUIModule"><HintPath>$(UnityManaged)\UnityEngine.IMGUIModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UnityWebRequestModule"><HintPath>$(UnityManaged)\UnityEngine.UnityWebRequestModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.AssetBundleModule"><HintPath>$(UnityManaged)\UnityEngine.AssetBundleModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UIModule"><HintPath>$(UnityManaged)\UnityEngine.UIModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.TextRenderingModule"><HintPath>$(UnityManaged)\UnityEngine.TextRenderingModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UI"><HintPath>$(ScriptAssemblies)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="XWorld.Link"><HintPath>$(ScriptAssemblies)\XWorld.Link.dll</HintPath><Private>false</Private></Reference>
<Reference Include="XWorld.Framework.Shared"><HintPath>$(ScriptAssemblies)\XWorld.Framework.Shared.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
```
- [ ] **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 视图(JsonUtilityC# 字段名须与 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<string>();
private string[] _gameNames = Array.Empty<string>();
private int _selected;
private int _version = 1;
private bool _pc, _android, _ios, _webgl;
private Vector2 _scroll;
public static void Open()
{
var w = GetWindow<MiniGamePublishWindow>("生成小游戏更新");
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<string>();
_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<MiniGamePublishSpec>(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<MiniGamePublishPipeline.PlatformChoice>();
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/<id>/<ver>/<platform>/(客户端)、deploy/minigame/<id>/<ver>/(服务器)。
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<string> 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/<gameId>/ 下已有数字目录 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<PlatformChoice> 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<AssetBundleBuild>
{
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<string> { ResAbName } : new List<string>(),
};
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<string>();
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 但按 `<platform>` 目录形状落到临时 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/<ver>/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)↔ CLITask 4)↔ `ResolvedSpecJson.codeAb`Task 9)↔ `MiniGameManifest.CodeAb`Task 7);AB 名 `code.unity3d`/`res.unity3d` 全链一致;`PublishServer/PublishClient` 签名在 Task 3/4/9 一致 ✓
- **无 git**:所有 commit 步骤已替换为验证 checkpoint ✓