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

68 KiB
Raw Blame History

小游戏打包流程(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 加 CodeAbWriteClient 输出 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.csPublishLayoutTests.cs;建 GameSpecJsonTests.csCliOptionsTests.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.csSpec 夹具加 CodeAb、Assets 改为完整文件名,Client_Json_HasRequiredFields 加 codeAb 断言:

        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 内追加/替换断言:

            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 行后):

        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 改为:

        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

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
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 整文件替换为:

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 整文件替换为:
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

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
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
<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

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:

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,真实文件)
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.sigout-client 有 code_<md5>.unity3d res_<md5>.unity3d game.json files.txt files.txt.sigfiles.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.csServer/games-src/RPS/RPS.Core/RpsTypes.cs

  • Modify: Client/HotUpdateGames/RPS.Client/RPS.Client.csproj

  • Step 5.1: 先核对两份 Core 源码语义等价(删除前提;已知有格式漂移)

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 整文件替换为:
<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.csRpsTypes.cs

  • Step 5.3: 改 RPS.Client.csproj——<Compile> 只留 Client 子目录,并加 RPS.Core 工程引用。将
    <Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\**\*.cs" />

替换为:

    <Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts~\Client\**\*.cs" />

并在 Framework.Shared 的 <ItemGroup> 中追加:

    <ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
      <Private>false</Private>
    </ProjectReference>
  • Step 5.4: 构建验证 + 产物形状断言
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: 刷新测试夹具 + 全量回归
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: 写文件(内容即模板,新游戏拷贝改字段):

{
  "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.csPublishTool.Tests.csproj 链接编译——改动必须保持纯 C#(不引 UnityEngine),每步后跑 dotnet test Server/PublishTool.Tests 即时验证该文件。

  • Step 7.1: 新建 MiniGamePlatform.cs
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 后)加:
        public string CodeAb;   // 代码 AB 文件名(统一 ABCore/Client DLL 的 .bytes 都在里面)

ParseGameJson 的对象初始化里(ClientDll = ... 行后)加:

                CodeAb = JsonMini.GetString(json, "codeAb"),

校验区(coreDll/clientDll 检查后)加:

            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 属性改为:
        private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/{MiniGamePlatform.Name}/";

LocalDir 不变:设备平台固定,无需平台段。)

  • Step 7.5: MiniGameAssemblyLoader 整文件替换
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
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; 后)加:

        private MiniGameAssetLoader _assets;

CoEnter 中构造 ctx 的代码,将

            _ctx = new GameClientCtx(
                new UnityAssetLoader(),

改为:

            _assets = new MiniGameAssetLoader(dl.LocalDir, dl.Manifest);
            _ctx = new GameClientCtx(
                _assets,

ExitGame 中,将

            _net = null;
            _ctx = null;
            // 卸载本小游戏 ABXResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项

改为:

            _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
<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 整文件替换
#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
#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../../ = 仓库根):

    private const string MINI_GAME_SERVER_GAMES_ROOT = "../../deploy/minigame";
  • Step 10.2: 改缺目录提示StartMiniGameGatewayRunnerProcess 内):
            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.ps1Tools/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 全量回归
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.CodeAbTask 1)↔ GameSpecJsonTask 2)↔ PublishClientTask 3)↔ CLITask 4)↔ ResolvedSpecJson.codeAbTask 9)↔ MiniGameManifest.CodeAbTask 7);AB 名 code.unity3d/res.unity3d 全链一致;PublishServer/PublishClient 签名在 Task 3/4/9 一致 ✓
  • 无 git:所有 commit 步骤已替换为验证 checkpoint ✓