Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,711 @@
|
||||
# 客户端小游戏加载器 + 生命周期框架(§9 第 3 步)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
>
|
||||
> ⚠️ **本计划是 Unity / HybridCLR 客户端代码,必须在 Unity Editor 中实施与验证。** 当前 .NET 沙箱无 Unity,无法 `dotnet test`。每个任务的验证步骤是 **Unity 编辑器编译 + Play 模式人工核对**(标注「[Unity 人工核对]」)。这与子项目 1 的 asmdef 处理一致。
|
||||
|
||||
**Goal:** 在客户端(`XWorld.Link` 热更程序集)实现一个 C# 小游戏加载器与生命周期框架:玩家进入某小游戏时,按「小游戏 manifest」增量下载并校验其文件 → HybridCLR 加载该小游戏的 `Core`/`Client` 热更 DLL → 加载其 AssetBundle → 反射 `game.json` 指定的 `IGameClient` 入口 → 注入客户端 ctx(资源/网络/退出)→ 驱动 `OnEnter`/`OnNetMessage`/`OnUpdate`/`OnExit`;并用框架 `FrameCodec`/`FrameworkMessages` 在 `XWebSocket` 上收发帧,取代 Lua `game_module_runner` 的 C# 小游戏路径。
|
||||
|
||||
**Architecture:** 复用并扩展现有 `LoadDll` 的 md5 清单/增量下载(设计 C1)叠加「小游戏 manifest」维度;复用 `XResLoader` 加载 AB、`XWebSocket`(经 `lsocket`)做传输、`XWCoroutine` 跑协程。加载器与生命周期宿主放在 `XWorld.Link`(热更),引用常驻的 `XWorld.Framework.Shared`(`IGameClient`/`NetMessage`/`FrameCodec` 等契约类型,AOT 不随小游戏卸载)。小游戏的 `Core`/`Client` DLL 由 HybridCLR `Assembly.Load` 动态载入;入口类型按 `game.json` 反射实例化。**本步骤不接服务端匹配**(用既有/手填 roomId 直连验证),与服务端 2b-1 的 `Channel.Framework`/`Channel.Game` 帧约定对接。
|
||||
|
||||
**Tech Stack:** Unity + HybridCLR · `XWorld.Link`(热更,`namespace XGame.MiniGame`)· 引用 `XWorld.Framework.Shared`(AOT)· `XResLoader`(AB)· `lsocket`/`XWebSocket`(WS)· `XWCoroutine`(协程)· `FrameCodec`/`FrameworkMessages`(协议,子项目 1)。
|
||||
|
||||
---
|
||||
|
||||
## 背景与约定(务必遵守)
|
||||
|
||||
- 上游设计:`docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md` §3.2(客户端进入→下载→加载→运行)、§5(`IGameClient`/`IGameClientCtx`/`IAssetLoader` 契约)。
|
||||
- 已完成:子项目 1(`Client/Assets/Framework/Shared/`,含 `IGameClient`/`IGameClientCtx`/`IAssetLoader`/`NetMessage`/`Protocol.FrameCodec`/`FrameworkMessages`,asmdef `XWorld.Framework.Shared`,AOT 常驻、不入 HybridCLR 热更列表)。服务端 2a/2b-1 已用同一套 `Frame`/`Channel`/`FrameworkOpcode` 协议。
|
||||
- 现有客户端关键 API(勘察确认,全部 `namespace XGame`):
|
||||
- `XResLoader.coLoadRes(string path, Type type, Action<UnityEngine.Object> action)`(协程异步加载,`path` 形如 `Assets/Game/Art/...` 自动剥前缀);`XResLoader.LoadRes(path, type)`(同步);`XResLoader.coLoadAB(string abpath, Action<AssetBundle>)`。
|
||||
- `lsocket.connect(string addr, int port, Action<XWebSocket,string> cb)` → `XWebSocket`:`int send(byte[] data, Action<int,string> cb, ref string err)`、`byte[] recv(ref string err)`(拉模型,每帧轮询)、`bool IsConected()`、`void close()`。
|
||||
- `XWCoroutine.Instance.StartCo(IEnumerator)`。
|
||||
- `LoadDll`:`GetFileMd5Name(filename, md5)`(静态私有,本步骤需在小游戏下载器内复刻同款逻辑)、CDN 约定 `{UpdateServer}{name}_{md5}{ext}`、`Application.persistentDataPath` 落盘。
|
||||
- 小游戏根:`GameObject.Find("Main")`。
|
||||
- **依赖铁律**:加载器/宿主依赖 `XWorld.Framework.Shared` 接口;小游戏 `Client.dll` 实现 `IGameClient`,二者都引用同一份(AOT)`XWorld.Framework.Shared`,保证反射出的接口类型一致。
|
||||
- **无 git**:跳过所有 git 步骤。
|
||||
|
||||
## 「小游戏 manifest」与发布产物(与 §9 第 4 步发布工具链对齐)
|
||||
|
||||
每个小游戏在 CDN 上的目录约定(第 4 步工具产出,本步骤消费):
|
||||
|
||||
```
|
||||
{UpdateServer}minigame/{gameId}/{version}/
|
||||
game.json # 元数据:gameId/version/clientEntryType/coreDll/clientDll/asset 列表/最小框架版本
|
||||
{gameId}.Core_{md5}.dll.bytes # 热更 Core DLL(md5 命名,沿用 GetFileMd5Name 风格)
|
||||
{gameId}.Client_{md5}.dll.bytes# 热更 Client DLL
|
||||
{abName}_{md5}.unity3d # 资源 AB 若干
|
||||
files.txt # 本小游戏文件清单:{'name','md5',size} 行(沿用现有 dllfiles 格式)
|
||||
```
|
||||
|
||||
`game.json`(客户端读取字段):
|
||||
|
||||
```json
|
||||
{
|
||||
"gameId": "rps",
|
||||
"version": 1,
|
||||
"clientEntryType": "RPS.Client.RpsGameClient",
|
||||
"coreDll": "RPS.Core.dll",
|
||||
"clientDll": "RPS.Client.dll",
|
||||
"minFrameworkVersion": 1,
|
||||
"assets": ["assets_game_art_ui_prefab"]
|
||||
}
|
||||
```
|
||||
|
||||
## 文件结构(全部位于 `Client/Assets/Script/xmain/MiniGame/`,namespace `XGame.MiniGame`)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `MiniGameManifest.cs` | `game.json` + `files.txt` 模型 + 解析(纯 C#,唯一可在 .NET 侧抽测的部分) |
|
||||
| `MiniGameDownloader.cs` | 按 manifest 增量下载+md5 校验小游戏文件到 persistentData(复用 LoadDll 约定) |
|
||||
| `MiniGameAssemblyLoader.cs` | HybridCLR:必要时补 AOT 元数据 + `Assembly.Load(Core, Client)`,反射入口类型 |
|
||||
| `UnityAssetLoader.cs` | `IAssetLoader` over `XResLoader` |
|
||||
| `MiniGameNetChannel.cs` | `XWebSocket` 上用 `FrameCodec` 收发;Game 帧↔`NetMessage`;框架帧(MatchFound/Snapshot/RoomEnd)事件 |
|
||||
| `GameClientCtx.cs` | `IGameClientCtx` 实现:Assets/Logger/Send/Exit |
|
||||
| `MiniGameHost.cs` | 编排:进入→下载→加载→反射→ctx→OnEnter;每帧 OnUpdate + 轮询网络;退出→OnExit+卸载 |
|
||||
|
||||
> 可抽测部分(`MiniGameManifest` 解析)建议**镜像一份到 `Server/` 下做 xUnit 抽测**(与 Hello manifest 同法),其余 Unity 部分仅 Editor 编译 + Play 核对。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 小游戏 manifest 模型与解析(可 .NET 抽测)
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameManifest.cs`
|
||||
- (可选抽测)Create: `Server/MiniGameManifest.Tests/...`(镜像源码 + xUnit,验证解析;非必须)
|
||||
|
||||
> 本类是纯 C#(无 UnityEngine)。建议把它**也**放进一个 .NET 测试工程做往返/校验抽测(与子项目 2a 的 `GameManifest` 同法),因为它是加载链路里唯一不依赖 Unity 的逻辑。Unity 侧只需编译通过。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGameManifest.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// game.json 客户端视图(字段名大小写不敏感解析)
|
||||
public sealed class MiniGameManifest
|
||||
{
|
||||
public string GameId;
|
||||
public int Version;
|
||||
public string ClientEntryType;
|
||||
public string CoreDll;
|
||||
public string ClientDll;
|
||||
public int MinFrameworkVersion;
|
||||
public List<string> Assets = new List<string>();
|
||||
|
||||
// files.txt 解析出的 name->(md5,size),用于增量下载校验
|
||||
public Dictionary<string, FileEntry> Files = new Dictionary<string, FileEntry>();
|
||||
|
||||
public sealed class FileEntry { public string Md5; public int Size; }
|
||||
|
||||
// 极简手写 JSON 解析(避免引入 Unity JsonUtility 的字段限制;只认本 schema 的扁平字段 + assets 字符串数组)
|
||||
// 注意:生产可换成项目已用的 JSON 库;此实现仅解析本 schema,键大小写不敏感。
|
||||
public static MiniGameManifest ParseGameJson(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) throw new FormatException("game.json 为空");
|
||||
var m = new MiniGameManifest
|
||||
{
|
||||
GameId = JsonMini.GetString(json, "gameId"),
|
||||
Version = JsonMini.GetInt(json, "version"),
|
||||
ClientEntryType = JsonMini.GetString(json, "clientEntryType"),
|
||||
CoreDll = JsonMini.GetString(json, "coreDll"),
|
||||
ClientDll = JsonMini.GetString(json, "clientDll"),
|
||||
MinFrameworkVersion = JsonMini.GetInt(json, "minFrameworkVersion"),
|
||||
};
|
||||
m.Assets = JsonMini.GetStringArray(json, "assets");
|
||||
if (string.IsNullOrEmpty(m.GameId)) throw new FormatException("game.json 缺少 gameId");
|
||||
if (string.IsNullOrEmpty(m.ClientEntryType)) throw new FormatException("game.json 缺少 clientEntryType");
|
||||
if (string.IsNullOrEmpty(m.CoreDll) || string.IsNullOrEmpty(m.ClientDll)) throw new FormatException("game.json 缺少 coreDll/clientDll");
|
||||
if (m.Version <= 0) throw new FormatException("game.json version 必须为正");
|
||||
return m;
|
||||
}
|
||||
|
||||
// files.txt 行格式(沿用 dllfiles):{'name','md5',size},
|
||||
public void LoadFilesList(string content)
|
||||
{
|
||||
Files.Clear();
|
||||
if (string.IsNullOrEmpty(content)) return;
|
||||
foreach (var raw in content.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.Length < 5) continue;
|
||||
// 去掉首 { 与尾 }, ,再去引号
|
||||
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
|
||||
if (lb < 0 || rb <= lb) continue;
|
||||
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
|
||||
string[] p = inner.Split(',');
|
||||
if (p.Length < 3) continue;
|
||||
int size; int.TryParse(p[2].Trim(), out size);
|
||||
Files[p[0].Trim()] = new FileEntry { Md5 = p[1].Trim(), Size = size };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 极小 JSON 取值器(仅供本 schema;非通用)
|
||||
internal static class JsonMini
|
||||
{
|
||||
public static string GetString(string json, string key)
|
||||
{
|
||||
int i = FindKey(json, key); if (i < 0) return null;
|
||||
int c = json.IndexOf(':', i); if (c < 0) return null;
|
||||
int q1 = json.IndexOf('"', c + 1); if (q1 < 0) return null;
|
||||
int q2 = json.IndexOf('"', q1 + 1); if (q2 < 0) return null;
|
||||
return json.Substring(q1 + 1, q2 - q1 - 1);
|
||||
}
|
||||
public static int GetInt(string json, string key)
|
||||
{
|
||||
int i = FindKey(json, key); if (i < 0) return 0;
|
||||
int c = json.IndexOf(':', i); if (c < 0) return 0;
|
||||
int j = c + 1; while (j < json.Length && (json[j] == ' ' || json[j] == '\t')) j++;
|
||||
int s = j; while (j < json.Length && (char.IsDigit(json[j]) || json[j] == '-')) j++;
|
||||
int v; int.TryParse(json.Substring(s, j - s), out v); return v;
|
||||
}
|
||||
public static System.Collections.Generic.List<string> GetStringArray(string json, string key)
|
||||
{
|
||||
var list = new System.Collections.Generic.List<string>();
|
||||
int i = FindKey(json, key); if (i < 0) return list;
|
||||
int lb = json.IndexOf('[', i); int rb = json.IndexOf(']', lb < 0 ? i : lb);
|
||||
if (lb < 0 || rb <= lb) return list;
|
||||
string inner = json.Substring(lb + 1, rb - lb - 1);
|
||||
foreach (var part in inner.Split(','))
|
||||
{
|
||||
int q1 = part.IndexOf('"'); int q2 = part.LastIndexOf('"');
|
||||
if (q1 >= 0 && q2 > q1) list.Add(part.Substring(q1 + 1, q2 - q1 - 1));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
private static int FindKey(string json, string key)
|
||||
{
|
||||
// 大小写不敏感找 "key"
|
||||
string needle = "\"" + key + "\"";
|
||||
int idx = json.IndexOf(needle, StringComparison.OrdinalIgnoreCase);
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: [Unity 人工核对] 编辑器编译**
|
||||
|
||||
打开 `Client/` Unity 工程,等待编译,确认 `XGame.MiniGame.MiniGameManifest` 无编译错误。
|
||||
(可选)把 `MiniGameManifest.cs` 复制进一个 .NET xUnit 工程,断言 `ParseGameJson`(有效/缺字段抛错)与 `LoadFilesList`(解析 `{'a','md5',12},` 行)。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 小游戏增量下载器 MiniGameDownloader
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameDownloader.cs`
|
||||
|
||||
> 复用 LoadDll 的 md5 命名/落盘约定(设计 C1:叠加小游戏维度,不另起炉灶)。本步骤用 `UnityWebRequest` 下载 `game.json`、`files.txt`、各 DLL/AB 到 `persistentDataPath/minigame/{gameId}/{version}/`,按 `files.txt` 的 md5 跳过已存在文件。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGameDownloader.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// 下载并校验某小游戏版本的全部文件;产出本地目录 + 解析好的 manifest
|
||||
public sealed class MiniGameDownloader
|
||||
{
|
||||
public string LocalDir { get; private set; }
|
||||
public MiniGameManifest Manifest { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
|
||||
private readonly string _cdnBase; // 例如 http://.../minigame/
|
||||
private readonly string _gameId;
|
||||
private readonly int _version;
|
||||
|
||||
public MiniGameDownloader(string cdnBase, string gameId, int version)
|
||||
{
|
||||
_cdnBase = cdnBase.EndsWith("/") ? cdnBase : cdnBase + "/";
|
||||
_gameId = gameId; _version = version;
|
||||
LocalDir = $"{Application.persistentDataPath}/minigame/{gameId}/{version}/";
|
||||
}
|
||||
|
||||
private string RemoteBase => $"{_cdnBase}{_gameId}/{_version}/";
|
||||
|
||||
public IEnumerator Run(Action<bool> onDone)
|
||||
{
|
||||
Error = null;
|
||||
Directory.CreateDirectory(LocalDir);
|
||||
|
||||
// 1) game.json(每次拉取,文件小)
|
||||
string gameJson = null;
|
||||
yield return Get($"{RemoteBase}game.json", t => gameJson = t, b => { });
|
||||
if (gameJson == null) { Fail("下载 game.json 失败"); onDone(false); yield break; }
|
||||
try { Manifest = MiniGameManifest.ParseGameJson(gameJson); }
|
||||
catch (Exception e) { Fail("game.json 解析失败: " + e.Message); onDone(false); yield break; }
|
||||
if (Manifest.GameId != _gameId || Manifest.Version != _version)
|
||||
{ Fail("game.json 与请求的 gameId/version 不符"); onDone(false); yield break; }
|
||||
|
||||
// 2) files.txt(md5 清单)
|
||||
string filesTxt = null;
|
||||
yield return Get($"{RemoteBase}files.txt", t => filesTxt = t, b => { });
|
||||
if (filesTxt == null) { Fail("下载 files.txt 失败"); onDone(false); yield break; }
|
||||
Manifest.LoadFilesList(filesTxt);
|
||||
|
||||
// 3) 按清单逐个增量下载(md5 命名;本地已存在且 md5 匹配则跳过)
|
||||
foreach (var kv in Manifest.Files)
|
||||
{
|
||||
string name = kv.Key; string md5 = kv.Value.Md5;
|
||||
string localPath = LocalDir + name;
|
||||
if (File.Exists(localPath) && Md5OfFile(localPath) == md5) continue;
|
||||
|
||||
string remote = $"{RemoteBase}{Md5Name(name, md5)}";
|
||||
byte[] data = null;
|
||||
yield return Get(remote, t => { }, b => data = b);
|
||||
if (data == null) { Fail("下载失败: " + remote); onDone(false); yield break; }
|
||||
if (Md5OfBytes(data) != md5) { Fail("md5 不符: " + name); onDone(false); yield break; }
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
|
||||
File.WriteAllBytes(localPath, data);
|
||||
}
|
||||
|
||||
onDone(true);
|
||||
}
|
||||
|
||||
private void Fail(string msg) { Error = msg; Debug.LogError("[MiniGameDownloader] " + msg); }
|
||||
|
||||
// 与 LoadDll.GetFileMd5Name 同款:name.ext -> name_md5.ext
|
||||
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}";
|
||||
}
|
||||
|
||||
private static string Md5OfBytes(byte[] data)
|
||||
{
|
||||
using (var md5 = System.Security.Cryptography.MD5.Create())
|
||||
{
|
||||
var hash = md5.ComputeHash(data);
|
||||
var sb = new System.Text.StringBuilder(hash.Length * 2);
|
||||
foreach (var b in hash) sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
private static string Md5OfFile(string path) => Md5OfBytes(File.ReadAllBytes(path));
|
||||
|
||||
private IEnumerator Get(string url, Action<string> onText, Action<byte[]> onBytes)
|
||||
{
|
||||
using (var uwr = UnityWebRequest.Get(url))
|
||||
{
|
||||
yield return uwr.SendWebRequest();
|
||||
if (uwr.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
onBytes(uwr.downloadHandler.data);
|
||||
onText(uwr.downloadHandler.text);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"[MiniGameDownloader] GET 失败 {url}: {uwr.error}");
|
||||
onText(null); onBytes(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: [Unity 人工核对] 编辑器编译 + 本地 CDN 联调**
|
||||
|
||||
确认编译通过。准备一个本地静态服务器(或 streamingAssets)放置 `minigame/rps/1/` 目录(game.json/files.txt/DLL/AB),用一个临时菜单调用 `new MiniGameDownloader(cdn, "rps", 1).Run(...)`,确认文件落到 `persistentDataPath/minigame/rps/1/` 且 md5 校验通过、二次进入跳过下载。
|
||||
|
||||
---
|
||||
|
||||
## Task 3: HybridCLR 程序集加载器 MiniGameAssemblyLoader
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoader.cs`
|
||||
|
||||
> 小游戏 Core/Client 是热更 DLL(裁剪后由 HybridCLR 解释执行)。它们引用 AOT 的 `XWorld.Framework.Shared`,元数据已在主包,故**通常无需**再对它们补 AOT 元数据;但若小游戏用到了主包未实例化的 AOT 泛型,需要补对应 AOT 程序集元数据(与 LoadDll 的 `LoadMetadataForAOTAssemblies` 同理)。本步骤先做 `Assembly.Load`,AOT 泛型补元数据留作 §10 风险项实测。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGameAssemblyLoader.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
using XWorld.Framework;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// 加载小游戏 Core/Client 热更 DLL,反射出 IGameClient 入口工厂
|
||||
public sealed class MiniGameAssemblyLoader
|
||||
{
|
||||
public Func<IGameClient> CreateClient { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
|
||||
// localDir: MiniGameDownloader.LocalDir;manifest: 解析好的 game.json
|
||||
public bool Load(string localDir, MiniGameManifest manifest)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 文件名约定:<coreDll>.bytes / <clientDll>.bytes(去 .dll 留 .bytes 以走 TextAsset/字节流)
|
||||
byte[] coreBytes = File.ReadAllBytes(localDir + manifest.CoreDll + ".bytes");
|
||||
byte[] clientBytes = File.ReadAllBytes(localDir + manifest.ClientDll + ".bytes");
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: [Unity 人工核对] 编译 + 加载真 DLL**
|
||||
|
||||
确认编译通过。用第 4 步工具产出的 `rps` Core/Client DLL(`.bytes`)放入本地目录,调 `Load(...)`,确认 `CreateClient()` 能反射实例化且 `is IGameClient`(验证 AOT 接口类型跨界一致)。**重点验证设计 §6.6/§10**:HybridCLR 在目标平台(含微信 WASM)下加载这些热更 DLL 是否合规/可行。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 资源加载适配 UnityAssetLoader + 客户端 ctx
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/UnityAssetLoader.cs`
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/GameClientCtx.cs`
|
||||
|
||||
- [ ] **Step 1: 写 `UnityAssetLoader.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using XWorld.Framework;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// IAssetLoader over XResLoader(同步加载已下载/缓存的小游戏 AB 资源)
|
||||
public sealed class UnityAssetLoader : IAssetLoader
|
||||
{
|
||||
// path: 形如 "Assets/Game/Art/UI/Prefab/UI_RPS.prefab";XResLoader 内部剥 "Assets/" 前缀并按目录解析 AB
|
||||
public object Load(string path)
|
||||
{
|
||||
return XResLoader.LoadRes(path, typeof(UnityEngine.Object));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `GameClientCtx.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using XWorld.Framework;
|
||||
// 别名消歧:UnityEngine 也有遗留的 UnityEngine.ILogger,会与框架 ILogger 冲突(编译验证发现)
|
||||
using IFwkLogger = XWorld.Framework.ILogger;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// 注入给 IGameClient 的上下文:资源/日志/发送/退出
|
||||
public sealed class GameClientCtx : IGameClientCtx
|
||||
{
|
||||
public IAssetLoader Assets { get; }
|
||||
public IFwkLogger Logger { get; }
|
||||
|
||||
private readonly Action<NetMessage> _send;
|
||||
private readonly Action _exit;
|
||||
|
||||
public GameClientCtx(IAssetLoader assets, IFwkLogger logger, Action<NetMessage> send, Action exit)
|
||||
{
|
||||
Assets = assets; Logger = logger; _send = send; _exit = exit;
|
||||
}
|
||||
|
||||
public void Send(NetMessage message) => _send(message);
|
||||
public void Exit() => _exit();
|
||||
}
|
||||
|
||||
// 简单 Unity 日志器(实现框架 ILogger)
|
||||
public sealed class UnityLogger : IFwkLogger
|
||||
{
|
||||
private readonly string _prefix;
|
||||
public UnityLogger(string prefix = "[minigame]") { _prefix = prefix; }
|
||||
public void Info(string m) => Debug.Log($"{_prefix} {m}");
|
||||
public void Warn(string m) => Debug.LogWarning($"{_prefix} {m}");
|
||||
public void Error(string m) => Debug.LogError($"{_prefix} {m}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: [Unity 人工核对] 编译**
|
||||
|
||||
确认编译通过;`UnityAssetLoader`/`GameClientCtx`/`UnityLogger` 均实现对应框架接口。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 网络通道 MiniGameNetChannel(FrameCodec over XWebSocket)
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameNetChannel.cs`
|
||||
|
||||
> 与服务端 2b-1 帧约定对接:`Channel.Game` 帧 ↔ `NetMessage(opcode,payload)`;`Channel.Framework` 帧承载 MatchFound/Snapshot/RoomEnd/Heartbeat。`XWebSocket` 是**拉模型**(每帧 `recv`),但每条 WS 消息即一帧(与服务端 `Connection` 一致:一条 WS 消息 = 一个 `FrameCodec.Encode` 结果)。⚠️ 注意:现有 `XWebSocket.recv` 返回的是**累积字节流**,需要消息边界。本通道假定底层按 WS 消息边界投递(若 `XWebSocket` 现实现是字节流拼接,需在此加长度前缀分帧——见 Step 2 核对项)。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGameNetChannel.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// 客户端到服务端的帧通道。每帧轮询底层 socket,解码出的帧分发:
|
||||
// Game 帧 -> OnGameMessage(NetMessage)(交给 IGameClient.OnNetMessage)
|
||||
// Framework 帧 -> OnFrameworkFrame(Frame)(宿主处理 MatchFound/RoomEnd 等)
|
||||
public sealed class MiniGameNetChannel
|
||||
{
|
||||
private readonly XWebSocket _sock;
|
||||
public Action<NetMessage> OnGameMessage;
|
||||
public Action<Frame> OnFrameworkFrame;
|
||||
|
||||
public MiniGameNetChannel(XWebSocket sock) { _sock = sock; }
|
||||
|
||||
public bool IsConnected => _sock != null && _sock.IsConected();
|
||||
|
||||
// 业务输入:IGameClient 调 ctx.Send(NetMessage) -> 这里包成 Game 帧发出
|
||||
public void SendGame(NetMessage msg)
|
||||
{
|
||||
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, msg.Opcode, msg.Payload));
|
||||
SendRaw(frame);
|
||||
}
|
||||
|
||||
public void SendFramework(FrameworkOpcode op, byte[] payload)
|
||||
{
|
||||
byte[] frame = FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload));
|
||||
SendRaw(frame);
|
||||
}
|
||||
|
||||
private void SendRaw(byte[] frame)
|
||||
{
|
||||
string err = null;
|
||||
_sock.send(frame, null, ref err);
|
||||
if (!string.IsNullOrEmpty(err)) Debug.LogError("[MiniGameNetChannel] send err: " + err);
|
||||
}
|
||||
|
||||
// 每帧由宿主调用:把底层收到的(按消息边界的)帧解出并分发
|
||||
public void Pump()
|
||||
{
|
||||
if (_sock == null) return;
|
||||
string err = null;
|
||||
// 约定:recv 每次返回 0 或 1 条完整 WS 消息的字节(见类头注意事项)
|
||||
while (_sock.isDataRecv())
|
||||
{
|
||||
byte[] data = _sock.recv(ref err);
|
||||
if (data == null || data.Length == 0) break;
|
||||
Frame f;
|
||||
try { f = FrameCodec.Decode(data); }
|
||||
catch (FormatException) { Debug.LogWarning("[MiniGameNetChannel] 畸形帧,丢弃"); continue; }
|
||||
|
||||
if (f.Channel == Channel.Game) OnGameMessage?.Invoke(new NetMessage(f.Opcode, f.Payload));
|
||||
else OnFrameworkFrame?.Invoke(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: [Unity 人工核对] 核对 XWebSocket 分帧语义 + 联调**
|
||||
|
||||
⚠️ **关键核对**:确认现有 `XWebSocket.recv` 是否按 WS 消息边界返回单条消息。若它返回拼接字节流(勘察显示其内部是 64KB ring buffer),则**必须**在 `Pump` 里改为长度前缀分帧:服务端 `Connection` 发的是「一条 WS 消息 = 一帧」,但若客户端 `recv` 不保边界,则需要服务端与客户端都加 `[u32 len]` 前缀。**这是上线前必须实测拍板的协议细节**(记入 §10 风险)。
|
||||
联调:连本地 2b-1 服务端,发 MatchRequest,确认能 `Pump` 出 MatchFound + Game 快照帧并正确解码(tick/counter)。
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 生命周期宿主 MiniGameHost
|
||||
|
||||
**Files:**
|
||||
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`
|
||||
|
||||
> 编排整条链路:进入 → 下载 → 加载 DLL → 反射 → 连接/发 MatchRequest → 收 MatchFound → 建 ctx → `OnEnter` → 每帧 `Pump` + `OnUpdate` → 收 Game 帧 `OnNetMessage` → 收 RoomEnd/退出 → `OnExit` + 卸载 AB + 销毁根节点。挂在 `Main` 上的 `MonoBehaviour`。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGameHost.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace XGame.MiniGame
|
||||
{
|
||||
// 一次小游戏会话的宿主。EnterGame 启动整条链路;每帧驱动;ExitGame 收尾。
|
||||
public sealed class MiniGameHost : MonoBehaviour
|
||||
{
|
||||
private IGameClient _client;
|
||||
private MiniGameNetChannel _net;
|
||||
private GameClientCtx _ctx;
|
||||
private bool _running;
|
||||
private string _gameId;
|
||||
private int _version;
|
||||
private string _roomId;
|
||||
|
||||
// 入口:cdnBase 小游戏 CDN 根;sock 已连接的 XWebSocket(来自大厅/或本宿主新建)
|
||||
public void EnterGame(string cdnBase, string gameId, int version, XWebSocket sock)
|
||||
{
|
||||
_gameId = gameId; _version = version;
|
||||
XWCoroutine.StartCo(CoEnter(cdnBase, gameId, version, sock)); // StartCo 是静态方法(编译验证发现,非 .Instance)
|
||||
}
|
||||
|
||||
private IEnumerator CoEnter(string cdnBase, string gameId, int version, XWebSocket sock)
|
||||
{
|
||||
// 1) 下载 + 校验
|
||||
var dl = new MiniGameDownloader(cdnBase, gameId, version);
|
||||
bool ok = false;
|
||||
yield return dl.Run(r => ok = r);
|
||||
if (!ok) { Debug.LogError("[MiniGameHost] 下载失败: " + dl.Error); yield break; }
|
||||
|
||||
// 2) 加载 Core/Client DLL,反射入口
|
||||
var asmLoader = new MiniGameAssemblyLoader();
|
||||
if (!asmLoader.Load(dl.LocalDir, dl.Manifest))
|
||||
{ Debug.LogError("[MiniGameHost] 加载程序集失败: " + asmLoader.Error); yield break; }
|
||||
|
||||
// 3) 网络通道
|
||||
_net = new MiniGameNetChannel(sock);
|
||||
_net.OnGameMessage = msg => { if (_client != null) SafeCall(() => _client.OnNetMessage(msg), "OnNetMessage"); };
|
||||
_net.OnFrameworkFrame = OnFrameworkFrame;
|
||||
|
||||
// 4) 反射实例化 IGameClient + 注入 ctx
|
||||
_client = asmLoader.CreateClient();
|
||||
_ctx = new GameClientCtx(
|
||||
new UnityAssetLoader(),
|
||||
new UnityLogger($"[{gameId}]"),
|
||||
msg => _net.SendGame(msg),
|
||||
() => ExitGame());
|
||||
|
||||
// 5) 请求进入房间(本步骤无匹配:直接发 MatchRequest 让 2b-1 单人建房;多人匹配在 §9 第 5 步)
|
||||
_net.SendFramework(FrameworkOpcode.MatchRequest,
|
||||
new MatchRequestMsg { GameId = gameId, Version = version }.Encode());
|
||||
|
||||
// 6) OnEnter 接管
|
||||
SafeCall(() => _client.OnEnter(_ctx), "OnEnter");
|
||||
_running = true;
|
||||
Debug.Log("[MiniGameHost] 小游戏已进入: " + gameId);
|
||||
}
|
||||
|
||||
private void OnFrameworkFrame(Frame f)
|
||||
{
|
||||
switch ((FrameworkOpcode)f.Opcode)
|
||||
{
|
||||
case FrameworkOpcode.MatchFound:
|
||||
_roomId = MatchFoundMsg.Decode(f.Payload).RoomId;
|
||||
Debug.Log("[MiniGameHost] 房间就绪: " + _roomId);
|
||||
break;
|
||||
case FrameworkOpcode.RoomEnd:
|
||||
Debug.Log("[MiniGameHost] 房间结束,退出小游戏");
|
||||
ExitGame();
|
||||
break;
|
||||
// Snapshot/Heartbeat 等:本步骤交给小游戏自行通过 Game 帧处理;如需框架级快照可在此扩展
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_running) return;
|
||||
_net?.Pump();
|
||||
if (_client != null) SafeCall(() => _client.OnUpdate(Time.deltaTime), "OnUpdate");
|
||||
}
|
||||
|
||||
public void ExitGame()
|
||||
{
|
||||
if (!_running && _client == null) return;
|
||||
_running = false;
|
||||
if (_client != null) { SafeCall(() => _client.OnExit(), "OnExit"); _client = null; }
|
||||
_net = null;
|
||||
// 卸载本小游戏 AB(XResLoader 按 xid/AB 名管理;此处按需卸载)—— 见 Step 2 核对项
|
||||
Debug.Log("[MiniGameHost] 已退出小游戏: " + _gameId);
|
||||
// 返回大厅:交回 Lua game_module_runner 或大厅 UI(按现有大厅返回流程)
|
||||
}
|
||||
|
||||
private static void SafeCall(Action a, string where)
|
||||
{
|
||||
try { a(); }
|
||||
catch (Exception e) { Debug.LogError($"[MiniGameHost] 小游戏 {where} 抛异常: {e}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: [Unity 人工核对] 端到端 Play 模式**
|
||||
|
||||
把 `MiniGameHost` 加到 `Main`,提供本地 2b-1 服务端 + 本地 CDN(放 rps 产物),调 `EnterGame(cdn, "rps", 1, sock)`:
|
||||
- 确认链路:下载→加载 DLL→反射→连接→MatchRequest→MatchFound→OnEnter→每帧 Pump/OnUpdate→收 Game 快照→OnNetMessage→RoomEnd→OnExit。
|
||||
- AB 卸载/根节点销毁/内存回收按现有 `XResLoader` 卸载机制核对(与 §10「客户端内存可控」一致)。
|
||||
- 退出后能正常返回大厅。
|
||||
|
||||
---
|
||||
|
||||
## 完成判据(Definition of Done)—— 均为 Unity 人工核对
|
||||
|
||||
- [ ] `Client/` 在 Unity Editor 编译 0 错误;`XGame.MiniGame` 全部类型引用 `XWorld.Framework.Shared`(AOT)正常。
|
||||
- [ ] 小游戏增量下载 + md5 校验通过;二次进入跳过已存在文件。
|
||||
- [ ] HybridCLR 加载小游戏 Core/Client 热更 DLL 成功,反射 `IGameClient` 入口并 `is IGameClient`(跨界类型一致)。
|
||||
- [ ] `MiniGameNetChannel` 与 2b-1 服务端帧约定互通(Game/Framework 双通道);分帧语义已核对拍板。
|
||||
- [ ] 端到端:进入→下载→加载→运行→收快照→结束→退出→返回大厅闭环跑通;AB/内存可控。
|
||||
- [ ] 共享层(`XWorld.Framework.Shared`)零改动;不引入对具体小游戏的静态依赖。
|
||||
|
||||
## 风险/待实测(记入设计 §10)
|
||||
|
||||
- **微信小游戏 WASM 下 HybridCLR 加载小游戏热更 DLL 的合规性与可行性**(设计 §6.6)——上线前必须实测;不行则降级为主包内置 + 仅资源/增量热更。
|
||||
- **`XWebSocket.recv` 的消息边界**:若为字节流拼接,需与服务端统一加 `[u32 len]` 分帧前缀(Task 5 核对项)。
|
||||
- **AOT 泛型补元数据**:小游戏若用到主包未实例化的 AOT 泛型,需补对应 AOT 程序集元数据(Task 3)。
|
||||
- **AB 卸载边界**:小游戏退出时 AB/资源/根节点的卸载与内存回收需实测无泄漏。
|
||||
|
||||
## 实施验证与修正(2026-06-20)
|
||||
|
||||
7 个加载器文件已落盘到 `Client/Assets/Script/xmain/MiniGame/`,并用 **csc 针对真实 Unity 2022.3.62f2 + `XWorld.Link.dll` + `XWorld.Framework.Shared.dll` 编译验证通过(0 错误)**——把「编译/类型」从人工核对升级为**实际验证**(运行时行为仍需 Unity Play 模式)。`XWorld.Link.asmdef` 已加 `XWorld.Framework.Shared` 引用。
|
||||
|
||||
**编译验证发现并修正的真实 API 不符(人工核对会遗漏):**
|
||||
1. `XWCoroutine.StartCo(...)` 是**静态**方法(非 `.Instance.StartCo`)。
|
||||
2. `ILogger` 与 `UnityEngine.ILogger`(遗留)歧义 → `GameClientCtx.cs` 用 `using IFwkLogger = XWorld.Framework.ILogger;` 别名。
|
||||
3. 编译需引用 netstandard2.1 + mscorlib 兼容 shim(编译环境细节)。
|
||||
|
||||
**逻辑审查修正(保持编译干净,已复验):**
|
||||
4. `JsonMini.GetStringArray`:键存在但无 `[` 时直接返回空(防误读其他 `]`)。
|
||||
5. `JsonMini.FindKey`:要求匹配的 `"key"` 后跟 `:` 才算键(防值内嵌同名文本误匹配)。
|
||||
6. `MiniGameNetChannel.Pump`:`recv` 返回 err 时 `break`(不再继续消耗)。
|
||||
7. `MiniGameHost`:send lambda 加 `_net` null 守卫 + `ExitGame` 末尾 `_ctx=null`(防退出后晚到 Send 的 NRE)。
|
||||
8. `MiniGameDownloader`:`Get` 拆为 `GetText`/`GetBytes`(二进制 DLL/AB 不做无谓 UTF-8 解码)。
|
||||
9. `MiniGameAssemblyLoader`:缺 `.bytes` 文件时给明确路径+约定的错误信息。
|
||||
10. `MiniGameHost.OnEnter` 处加注释:OnEnter 早于 MatchFound 到达、房间可能尚未就绪。
|
||||
|
||||
**加载前验签(设计 §7 客户端侧,2026-06-20 追加,已 csc 编译验证 + 跨组件实测):**
|
||||
- 新增 `ClientManifestVerifier.cs`(第 8 个文件):内置 RSA 公钥(modulus+exponent base64 常量,**发布前须填**),用 `RSAParameters`+`ImportParameters`+`VerifyData(SHA256,Pkcs1)`——netstandard2.0 即有、Unity mono 最兼容(避开 net5+ 的 `ImportFromPem`、`ImportRSAPublicKey`);公钥未配置时跳过(开发模式)。
|
||||
- `MiniGameManifest` 加 `Digest()`(按 name Ordinal 排序拼 `name=md5;`),与发布工具 `FilesManifest.Digest()` 完全一致。
|
||||
- `MiniGameDownloader` 下载 `files.txt.sig`,在下载/加载任何 DLL **之前**验签(失败即中止);随后每文件仍做 md5 校验。
|
||||
- **跨组件实证**:把纯 C# 的 `MiniGameManifest.cs` 链入 `Server/PublishTool.Tests`,断言客户端 `Digest()` == 服务端 `FilesManifest.Digest()`(逐字节、顺序无关,2 测试绿)——证明验签端到端成立。
|
||||
- 8 个加载器文件 csc 编译 0 错误(含 `System.Security.Cryptography`)。**Unity mono 上 RSA 运行时行为仍须 Play 核对。**
|
||||
|
||||
**仍须 Unity Play 模式人工核对(无法在此验证):** ① 客户端验签的 Unity mono 运行时 RSA(ImportParameters/VerifyData);② `XWebSocket.recv` 是否保证每次返回单条完整 WS 消息(否则需 `[u32 len]` 分帧);③ HybridCLR/IL2CPP(含 iOS、微信 WASM)下 `Assembly.Load(coreBytes)` 与 AOT 泛型补元数据;④ `XResLoader.LoadRes` 在 AB 未挂载时的行为;⑤ AB 卸载/内存回收;⑥ **微信 WASM 热更代码合规**(设计 §6.6 最大上线风险)。
|
||||
|
||||
## 不在本步骤范围
|
||||
|
||||
- 服务端匹配/AI 兜底/多人(§9 第 5 步)。
|
||||
- 大厅小游戏列表 UI 与「点击进入某小游戏」的入口接线(接现有 `GameLobbyController`,本步骤只提供 `MiniGameHost.EnterGame`)。
|
||||
- 发布工具链产出小游戏 manifest/DLL/AB(§9 第 4 步,本步骤消费其约定)。
|
||||
- 用 C# 重写 RPS 的具体玩法(§9 第 5 步的 `RPS.Client`)。
|
||||
@@ -0,0 +1,580 @@
|
||||
# 构建/发布工具链(§9 第 4 步)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use `- [ ]`.
|
||||
>
|
||||
> 本计划分两半:**(.NET 可 TDD)** 打包/签名/manifest 生成工具(`Server/PublishTool/`,`dotnet test` 验证);**(Unity 人工核对)** Editor 菜单:Core 双编译为热更 DLL + 打 AssetBundle。Unity 半段标注「[Unity 人工核对]」。
|
||||
|
||||
**Goal:** 一键把一个小游戏(Core/Client/Server 源码 + 资源)产出**两端发布包**:服务端目录 `games/{gameId}/{version}/`(Server.dll + Core.dll + game.json,2a 加载约定)与客户端 CDN 目录 `minigame/{gameId}/{version}/`(Core/Client 热更 DLL `.bytes` + AB + game.json + files.txt + 签名,第 3 步消费约定);版本号绑定,一次构建产出两端,避免逻辑漂移(设计 §3.1)。
|
||||
|
||||
**Architecture:** Core 按 A1 编两次(客户端 HybridCLR 热更 DLL + 服务端普通 .NET DLL)——这步依赖 Unity/HybridCLR 工具链,做成 Editor 菜单(人工)。所有**与平台无关的打包工作**(计算 md5、写 files.txt、生成两份 game.json、对清单签名、按目录布局落盘)抽到 `Server/PublishTool` .NET 库,`dotnet test` 全程可验。客户端/服务端加载前用同一 `ManifestSigner` 验签(设计 §7)。
|
||||
|
||||
**Tech Stack:** .NET 10 控制台/库 `Server/PublishTool` · `System.Security.Cryptography`(MD5 文件指纹 + RSA 清单签名)· xUnit · Unity Editor 菜单(HybridCLR `CompileDllCommand` + `BuildAssetBundles`,人工)。
|
||||
|
||||
---
|
||||
|
||||
## 背景与约定
|
||||
|
||||
- 上游:设计 §3.1(发布流程)、§7(签名校验)。消费方:2a 服务端 `GameModuleLoader`(`games/{id}/{ver}/`)、第 3 步客户端 `MiniGameDownloader`(`minigame/{id}/{ver}/`)。
|
||||
- 已完成 2a 的服务端 `game.json` schema(gameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 `game.json` schema(gameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion/assets)。本工具**两份都生成**。
|
||||
- **无 git**:跳过 git 步骤。工作目录 `D:/UD/AI/AIC#Project`,`dotnet` SDK 10。
|
||||
|
||||
## 发布产物布局(本工具产出)
|
||||
|
||||
```
|
||||
<outRoot>/server/games/{gameId}/{version}/
|
||||
{Game}.Server.dll {Game}.Core.dll game.json # 2a 加载(无 .bytes,普通 DLL)
|
||||
<outRoot>/client/minigame/{gameId}/{version}/
|
||||
{Game}.Core.dll.bytes {Game}.Client.dll.bytes # HybridCLR 热更 DLL(字节流)
|
||||
{abName}_{md5}.unity3d ... # AB(md5 命名)
|
||||
game.json files.txt files.txt.sig # 客户端清单 + 签名
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `Server/PublishTool/PublishTool.csproj` | net10.0 库 |
|
||||
| `Server/PublishTool/Md5Util.cs` | 文件/字节 md5 十六进制 |
|
||||
| `Server/PublishTool/ManifestSigner.cs` | RSA 对 files.txt 摘要签名 + 验签(客户端/服务端加载前用) |
|
||||
| `Server/PublishTool/FilesManifest.cs` | files.txt 模型 + 生成(`{'name','md5',size},` 行)+ 解析 |
|
||||
| `Server/PublishTool/GameJsonWriter.cs` | 生成服务端 + 客户端两份 game.json |
|
||||
| `Server/PublishTool/PublishLayout.cs` | 输入产物 → 落盘到上述目录布局 + 写 manifest + 签名 |
|
||||
| `Server/PublishTool.Tests/*` | xUnit |
|
||||
| `Client/Assets/Editor/MiniGamePublishMenu.cs` | [Unity] 菜单:Core 双编译 + 打 AB + 调 PublishLayout |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 工程脚手架
|
||||
|
||||
**Files:** Create `Server/PublishTool/PublishTool.csproj`, `Server/PublishTool.Tests/PublishTool.Tests.csproj`; modify `Server/Server.sln`.
|
||||
|
||||
- [ ] **Step 1:**
|
||||
```bash
|
||||
dotnet new classlib -n PublishTool -o Server/PublishTool -f net10.0
|
||||
dotnet new xunit -n PublishTool.Tests -o Server/PublishTool.Tests
|
||||
rm -f Server/PublishTool/Class1.cs Server/PublishTool.Tests/UnitTest1.cs
|
||||
dotnet sln Server/Server.sln add Server/PublishTool/PublishTool.csproj
|
||||
dotnet sln Server/Server.sln add Server/PublishTool.Tests/PublishTool.Tests.csproj
|
||||
```
|
||||
- [ ] **Step 2:** 在 `PublishTool.Tests.csproj` 加 `<Nullable>disable</Nullable>` + `<ProjectReference Include="..\PublishTool\PublishTool.csproj" />`;`PublishTool.csproj` 加 `<Nullable>disable</Nullable>`。
|
||||
- [ ] **Step 3:** Run `dotnet build Server/Server.sln -v minimal` → 0 error。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Md5Util(TDD)
|
||||
|
||||
**Files:** Create `Server/PublishTool/Md5Util.cs`; Test `Server/PublishTool.Tests/Md5UtilTests.cs`.
|
||||
|
||||
- [ ] **Step 1: 失败测试**
|
||||
```csharp
|
||||
using Xunit;
|
||||
using XWorld.PublishTool;
|
||||
|
||||
namespace XWorld.PublishTool.Tests
|
||||
{
|
||||
public class Md5UtilTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hex_OfKnownBytes_IsStableLowercase32()
|
||||
{
|
||||
// MD5("abc") = 900150983cd24fb0d6963f7d28e17f72
|
||||
var hex = Md5Util.OfBytes(System.Text.Encoding.ASCII.GetBytes("abc"));
|
||||
Assert.Equal("900150983cd24fb0d6963f7d28e17f72", hex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_IsKnownMd5()
|
||||
{
|
||||
Assert.Equal("d41d8cd98f00b204e9800998ecf8427e", Md5Util.OfBytes(new byte[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** Run `dotnet test Server/Server.sln --filter Md5UtilTests` → RED。
|
||||
- [ ] **Step 3: 实现**
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public static class Md5Util
|
||||
{
|
||||
public static string OfBytes(byte[] data)
|
||||
{
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
byte[] h = md5.ComputeHash(data ?? Array.Empty<byte>());
|
||||
var sb = new StringBuilder(h.Length * 2);
|
||||
foreach (var b in h) sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
public static string OfFile(string path) => OfBytes(File.ReadAllBytes(path));
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** Run filter → GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 3: FilesManifest(TDD)
|
||||
|
||||
**Files:** Create `Server/PublishTool/FilesManifest.cs`; Test `Server/PublishTool.Tests/FilesManifestTests.cs`.
|
||||
|
||||
- [ ] **Step 1: 失败测试**
|
||||
```csharp
|
||||
using Xunit;
|
||||
using XWorld.PublishTool;
|
||||
|
||||
namespace XWorld.PublishTool.Tests
|
||||
{
|
||||
public class FilesManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_And_Parse_RoundTrips()
|
||||
{
|
||||
var m = new FilesManifest();
|
||||
m.Add("RPS.Core.dll.bytes", "abc123", 100);
|
||||
m.Add("ui_rps_abc.unity3d", "def456", 2048);
|
||||
string text = m.Render();
|
||||
|
||||
var back = FilesManifest.Parse(text);
|
||||
Assert.Equal(2, back.Entries.Count);
|
||||
Assert.Equal("abc123", back.Entries["RPS.Core.dll.bytes"].Md5);
|
||||
Assert.Equal(2048, back.Entries["ui_rps_abc.unity3d"].Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_LineFormat_MatchesDllfilesConvention()
|
||||
{
|
||||
var m = new FilesManifest();
|
||||
m.Add("a.dll", "m5", 7);
|
||||
Assert.Equal("{'a.dll','m5',7},\n", m.Render());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Digest_IsStable_RegardlessOfInsertOrder()
|
||||
{
|
||||
var a = new FilesManifest(); a.Add("x", "1", 1); a.Add("y", "2", 2);
|
||||
var b = new FilesManifest(); b.Add("y", "2", 2); b.Add("x", "1", 1);
|
||||
Assert.Equal(a.Digest(), b.Digest()); // 按 name 排序后摘要,顺序无关
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** RED。
|
||||
- [ ] **Step 3: 实现**
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public sealed class FilesManifest
|
||||
{
|
||||
public sealed class Entry { public string Md5; public int Size; }
|
||||
public Dictionary<string, Entry> Entries { get; } = new Dictionary<string, Entry>();
|
||||
|
||||
public void Add(string name, string md5, int size) => Entries[name] = new Entry { Md5 = md5, Size = size };
|
||||
|
||||
// 行格式沿用 dllfiles:{'name','md5',size},\n(按 name 排序,保证确定性)
|
||||
public string Render()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
|
||||
sb.Append("{'").Append(kv.Key).Append("','").Append(kv.Value.Md5).Append("',").Append(kv.Value.Size).Append("},\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static FilesManifest Parse(string text)
|
||||
{
|
||||
var m = new FilesManifest();
|
||||
if (string.IsNullOrEmpty(text)) return m;
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}');
|
||||
if (lb < 0 || rb <= lb) continue;
|
||||
string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", "");
|
||||
string[] p = inner.Split(',');
|
||||
if (p.Length < 3) continue;
|
||||
int.TryParse(p[2].Trim(), out int size);
|
||||
m.Add(p[0].Trim(), p[1].Trim(), size);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// 稳定摘要:排序后的 name+md5 串(用于签名)
|
||||
public string Digest()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal))
|
||||
sb.Append(kv.Key).Append('=').Append(kv.Value.Md5).Append(';');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ManifestSigner(RSA 签名/验签,TDD)
|
||||
|
||||
**Files:** Create `Server/PublishTool/ManifestSigner.cs`; Test `Server/PublishTool.Tests/ManifestSignerTests.cs`.
|
||||
|
||||
> 发布方用私钥签 files.txt 的 `Digest()`,产出 `files.txt.sig`;客户端/服务端加载前用内置公钥验签(设计 §7:防中间人篡改 DLL)。
|
||||
|
||||
- [ ] **Step 1: 失败测试**
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using Xunit;
|
||||
using XWorld.PublishTool;
|
||||
|
||||
namespace XWorld.PublishTool.Tests
|
||||
{
|
||||
public class ManifestSignerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sign_Then_Verify_Succeeds()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
string priv = rsa.ExportRSAPrivateKeyPem();
|
||||
string pub = rsa.ExportRSAPublicKeyPem();
|
||||
|
||||
byte[] sig = ManifestSigner.Sign("digest-content", priv);
|
||||
Assert.True(ManifestSigner.Verify("digest-content", sig, pub));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_Fails_OnTamperedContent()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
byte[] sig = ManifestSigner.Sign("original", rsa.ExportRSAPrivateKeyPem());
|
||||
Assert.False(ManifestSigner.Verify("tampered", sig, rsa.ExportRSAPublicKeyPem()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_Fails_OnWrongKey()
|
||||
{
|
||||
using var signer = RSA.Create(2048);
|
||||
using var other = RSA.Create(2048);
|
||||
byte[] sig = ManifestSigner.Sign("c", signer.ExportRSAPrivateKeyPem());
|
||||
Assert.False(ManifestSigner.Verify("c", sig, other.ExportRSAPublicKeyPem()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** RED。
|
||||
- [ ] **Step 3: 实现**
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public static class ManifestSigner
|
||||
{
|
||||
public static byte[] Sign(string content, string privateKeyPem)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(privateKeyPem);
|
||||
return rsa.SignData(Encoding.UTF8.GetBytes(content), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
public static bool Verify(string content, byte[] signature, string publicKeyPem)
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(Encoding.UTF8.GetBytes(content), signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: GameJsonWriter(两份 game.json,TDD)
|
||||
|
||||
**Files:** Create `Server/PublishTool/GameJsonWriter.cs`; Test `Server/PublishTool.Tests/GameJsonWriterTests.cs`.
|
||||
|
||||
> 用 `System.Text.Json` 生成。服务端 schema 与 2a `GameManifest` 字段一致;客户端 schema 与第 3 步 `MiniGameManifest` 一致。用 2a 的 `GameManifest.Parse`/第 3 步字段名做往返断言(这里直接断言 JSON 含正确字段值)。
|
||||
|
||||
- [ ] **Step 1: 失败测试**
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using XWorld.PublishTool;
|
||||
|
||||
namespace XWorld.PublishTool.Tests
|
||||
{
|
||||
public class GameJsonWriterTests
|
||||
{
|
||||
private static readonly GameSpec Spec = new GameSpec
|
||||
{
|
||||
GameId = "rps", Version = 2, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
|
||||
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
|
||||
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
|
||||
Assets = new System.Collections.Generic.List<string> { "ui_rps" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Server_Json_HasRequiredFields()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(GameJsonWriter.WriteServer(Spec));
|
||||
var r = doc.RootElement;
|
||||
Assert.Equal("rps", r.GetProperty("gameId").GetString());
|
||||
Assert.Equal(2, r.GetProperty("version").GetInt32());
|
||||
Assert.Equal("RPS.Server.dll", r.GetProperty("serverAssembly").GetString());
|
||||
Assert.Equal("RPS.Server.RpsServerRoom", r.GetProperty("serverEntryType").GetString());
|
||||
Assert.Equal(2, r.GetProperty("playerCount").GetInt32());
|
||||
Assert.Equal(10, r.GetProperty("tickRateHz").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_Json_HasRequiredFields()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(GameJsonWriter.WriteClient(Spec));
|
||||
var r = doc.RootElement;
|
||||
Assert.Equal("rps", r.GetProperty("gameId").GetString());
|
||||
Assert.Equal("RPS.Client.RpsGameClient", r.GetProperty("clientEntryType").GetString());
|
||||
Assert.Equal("RPS.Core.dll", r.GetProperty("coreDll").GetString());
|
||||
Assert.Equal("RPS.Client.dll", r.GetProperty("clientDll").GetString());
|
||||
Assert.Equal("ui_rps", r.GetProperty("assets")[0].GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** RED。
|
||||
- [ ] **Step 3: 实现**
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
public sealed class GameSpec
|
||||
{
|
||||
public string GameId; public int Version; public int MinFrameworkVersion;
|
||||
public int PlayerCount; public int TickRateHz;
|
||||
public string ServerAssembly; public string ServerEntryType;
|
||||
public string CoreDll; public string ClientDll; public string ClientEntryType;
|
||||
public List<string> Assets = new List<string>();
|
||||
}
|
||||
|
||||
public static class GameJsonWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions { WriteIndented = true };
|
||||
|
||||
public static string WriteServer(GameSpec s) => JsonSerializer.Serialize(new
|
||||
{
|
||||
gameId = s.GameId, version = s.Version, serverAssembly = s.ServerAssembly,
|
||||
serverEntryType = s.ServerEntryType, minFrameworkVersion = s.MinFrameworkVersion,
|
||||
playerCount = s.PlayerCount, tickRateHz = s.TickRateHz
|
||||
}, Opts);
|
||||
|
||||
public static string WriteClient(GameSpec s) => JsonSerializer.Serialize(new
|
||||
{
|
||||
gameId = s.GameId, version = s.Version, clientEntryType = s.ClientEntryType,
|
||||
coreDll = s.CoreDll, clientDll = s.ClientDll, minFrameworkVersion = s.MinFrameworkVersion,
|
||||
assets = s.Assets
|
||||
}, Opts);
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 6: PublishLayout(落盘布局 + 清单 + 签名,TDD)
|
||||
|
||||
**Files:** Create `Server/PublishTool/PublishLayout.cs`; Test `Server/PublishTool.Tests/PublishLayoutTests.cs`.
|
||||
|
||||
> 输入:一个已构建产物的临时目录(Server.dll/Core.dll/Client.dll/AB)+ GameSpec + 私钥。输出:server/ 与 client/ 两套目录 + game.json×2 + files.txt + files.txt.sig。用临时目录测全流程,再用公钥验签 + 重新 md5 校验。
|
||||
|
||||
- [ ] **Step 1: 失败测试**
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Xunit;
|
||||
using XWorld.PublishTool;
|
||||
|
||||
namespace XWorld.PublishTool.Tests
|
||||
{
|
||||
public class PublishLayoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void Publish_ProducesBothTrees_WithValidSignatureAndMd5()
|
||||
{
|
||||
// 准备假产物
|
||||
string src = Path.Combine(Path.GetTempPath(), "pubsrc_" + System.Guid.NewGuid().ToString("N"));
|
||||
string outRoot = Path.Combine(Path.GetTempPath(), "pubout_" + System.Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(src);
|
||||
File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1, 2, 3 });
|
||||
File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 4, 5 });
|
||||
File.WriteAllBytes(Path.Combine(src, "RPS.Client.dll"), new byte[] { 6, 7, 8, 9 });
|
||||
File.WriteAllBytes(Path.Combine(src, "ui_rps.unity3d"), new byte[] { 10 });
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var spec = new GameSpec
|
||||
{
|
||||
GameId = "rps", Version = 1, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10,
|
||||
ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom",
|
||||
CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient",
|
||||
};
|
||||
spec.Assets.Add("ui_rps");
|
||||
|
||||
new PublishLayout().Publish(src, outRoot, spec, rsa.ExportRSAPrivateKeyPem());
|
||||
|
||||
string serverDir = Path.Combine(outRoot, "server", "games", "rps", "1");
|
||||
string clientDir = Path.Combine(outRoot, "client", "minigame", "rps", "1");
|
||||
|
||||
// 服务端:普通 DLL + game.json
|
||||
Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Server.dll")));
|
||||
Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Core.dll")));
|
||||
Assert.True(File.Exists(Path.Combine(serverDir, "game.json")));
|
||||
|
||||
// 客户端:热更 DLL 转 .bytes(md5 命名)+ AB(md5名) + game.json + files.txt + sig
|
||||
string coreMd5 = Md5Util.OfFile(Path.Combine(src, "RPS.Core.dll"));
|
||||
string abMd5 = Md5Util.OfFile(Path.Combine(src, "ui_rps.unity3d"));
|
||||
Assert.True(File.Exists(Path.Combine(clientDir, $"RPS.Core.dll_{coreMd5}.bytes")));
|
||||
Assert.True(File.Exists(Path.Combine(clientDir, $"ui_rps_{abMd5}.unity3d")));
|
||||
Assert.True(File.Exists(Path.Combine(clientDir, "game.json")));
|
||||
Assert.True(File.Exists(Path.Combine(clientDir, "files.txt")));
|
||||
Assert.True(File.Exists(Path.Combine(clientDir, "files.txt.sig")));
|
||||
|
||||
// 验签:用公钥验证 files.txt 的 digest
|
||||
var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(clientDir, "files.txt")));
|
||||
byte[] sig = File.ReadAllBytes(Path.Combine(clientDir, "files.txt.sig"));
|
||||
Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem()));
|
||||
|
||||
Directory.Delete(src, true); Directory.Delete(outRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** RED。
|
||||
- [ ] **Step 3: 实现**
|
||||
```csharp
|
||||
using System.IO;
|
||||
|
||||
namespace XWorld.PublishTool
|
||||
{
|
||||
// 把构建产物落成两端发布目录 + 清单 + 签名
|
||||
public sealed class PublishLayout
|
||||
{
|
||||
public void Publish(string srcDir, string outRoot, GameSpec spec, string privateKeyPem)
|
||||
{
|
||||
string serverDir = Path.Combine(outRoot, "server", "games", spec.GameId, spec.Version.ToString());
|
||||
string clientDir = Path.Combine(outRoot, "client", "minigame", spec.GameId, spec.Version.ToString());
|
||||
Directory.CreateDirectory(serverDir);
|
||||
Directory.CreateDirectory(clientDir);
|
||||
|
||||
// 服务端:普通 DLL 原样拷 + game.json
|
||||
CopyTo(srcDir, spec.ServerAssembly, serverDir, spec.ServerAssembly);
|
||||
CopyTo(srcDir, spec.CoreDll, serverDir, spec.CoreDll);
|
||||
File.WriteAllText(Path.Combine(serverDir, "game.json"), GameJsonWriter.WriteServer(spec));
|
||||
|
||||
// 客户端:Core/Client 转 .bytes(热更字节流)+ AB(md5名) + 清单
|
||||
var fm = new FilesManifest();
|
||||
string coreBytes = spec.CoreDll + ".bytes";
|
||||
string clientBytes = spec.ClientDll + ".bytes";
|
||||
CopyBytes(srcDir, spec.CoreDll, clientDir, coreBytes, fm);
|
||||
CopyBytes(srcDir, spec.ClientDll, clientDir, clientBytes, fm);
|
||||
foreach (var ab in spec.Assets)
|
||||
{
|
||||
string abFile = ab + ".unity3d";
|
||||
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, abFile));
|
||||
string md5 = Md5Util.OfBytes(data);
|
||||
// md5 命名落盘:name_md5.unity3d
|
||||
string md5Name = ab + "_" + md5 + ".unity3d";
|
||||
File.WriteAllBytes(Path.Combine(clientDir, md5Name), data);
|
||||
fm.Add(abFile, md5, data.Length);
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(clientDir, "game.json"), GameJsonWriter.WriteClient(spec));
|
||||
string filesTxt = fm.Render();
|
||||
File.WriteAllText(Path.Combine(clientDir, "files.txt"), filesTxt);
|
||||
byte[] sig = ManifestSigner.Sign(fm.Digest(), privateKeyPem);
|
||||
File.WriteAllBytes(Path.Combine(clientDir, "files.txt.sig"), sig);
|
||||
}
|
||||
|
||||
private static void CopyTo(string srcDir, string srcName, string dstDir, string dstName)
|
||||
=> File.Copy(Path.Combine(srcDir, srcName), Path.Combine(dstDir, dstName), true);
|
||||
|
||||
// 落盘为 md5 命名(name_md5.ext,与 AB / LoadDll 约定及下载器 Md5Name 推导一致),files.txt 登记逻辑名
|
||||
private static void CopyBytes(string srcDir, string srcDll, string dstDir, string logicalBytesName, FilesManifest fm)
|
||||
{
|
||||
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, srcDll));
|
||||
string md5 = Md5Util.OfBytes(data);
|
||||
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(logicalBytesName, md5)), data);
|
||||
fm.Add(logicalBytesName, md5, data.Length);
|
||||
}
|
||||
|
||||
// name.ext -> name_md5.ext(无扩展名则 name_md5)
|
||||
private static string Md5Name(string filename, string md5)
|
||||
{
|
||||
int pos = filename.LastIndexOf('.');
|
||||
return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}"
|
||||
: $"{filename}_{md5}";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** GREEN。
|
||||
- [ ] **Step 5:** Run `dotnet test Server/Server.sln` 全量绿,0 警告。
|
||||
|
||||
---
|
||||
|
||||
## Task 7: [Unity 人工核对] Editor 发布菜单
|
||||
|
||||
**Files:** Create `Client/Assets/Editor/MiniGamePublishMenu.cs`(仅 Editor 编译)。
|
||||
|
||||
> 这一段依赖 Unity/HybridCLR,**无法在 .NET 沙箱验证**,作为人工核对实施。
|
||||
|
||||
- [ ] **Step 1: 写 `MiniGamePublishMenu.cs`(要点,非完整)**
|
||||
```csharp
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
// using HybridCLR.Editor.Commands; // CompileDllCommand
|
||||
namespace XGame.Editor
|
||||
{
|
||||
public static class MiniGamePublishMenu
|
||||
{
|
||||
[MenuItem("XWorld/发布小游戏/RPS")]
|
||||
public static void PublishRps()
|
||||
{
|
||||
// 1) Core 双编译:
|
||||
// a. HybridCLR:CompileDllCommand.CompileDll(target) 产出热更 RPS.Core.dll / RPS.Client.dll(裁剪后字节)
|
||||
// b. 服务端 .NET:调用 `dotnet build` RPS.Core(netstandard2.1)/RPS.Server 产出普通 DLL(或复用 Server 解决方案构建产物)
|
||||
// 2) BuildPipeline.BuildAssetBundles(...) 打小游戏 AB(按目录命名),输出 ui_rps.unity3d
|
||||
// 3) 把以上产物汇集到临时 srcDir
|
||||
// 4) 调 .NET 工具:new XWorld.PublishTool.PublishLayout().Publish(srcDir, outRoot, spec, privateKeyPem)
|
||||
// (PublishTool 编成 DLL 供 Editor 引用,或用命令行调用一个 PublishTool 控制台封装)
|
||||
Debug.Log("发布完成(人工核对产物布局/签名)");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
- [ ] **Step 2: [Unity 人工核对]** 在 Editor 跑菜单,确认产出 `server/games/rps/1/` 与 `client/minigame/rps/1/`,文件齐全、签名可验(用 PublishTool 的 `ManifestSigner.Verify`)、md5 一致。Core 的两份 DLL(热更字节 vs 普通 .NET)来自**同一份 Core 源码**(A1)。
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
- [ ] `.NET` 段:`dotnet test Server/Server.sln` 全绿(Md5Util/FilesManifest/ManifestSigner/GameJsonWriter/PublishLayout 各测试),0 警告。
|
||||
- [ ] `PublishLayout.Publish` 产出两端目录、两份 game.json(字段分别匹配 2a 服务端 schema 与第 3 步客户端 schema)、files.txt(md5/size)、files.txt.sig(公钥可验、篡改即失败)。
|
||||
- [ ] [Unity 人工核对] Editor 菜单完成 Core 双编译 + 打 AB + 调 PublishLayout,产物可被 2a 服务端 `GameModuleLoader` 与第 3 步客户端 `MiniGameDownloader` 直接消费。
|
||||
|
||||
## 不在本步骤范围 / 风险
|
||||
|
||||
- 真正的「发布服务」后台(灰度/正式通道、CDN 上传、版本库)——本步骤只产出**本地布局**;上传/通道是运维集成,后续。
|
||||
- 客户端/服务端**加载前验签**的接线:把 `ManifestSigner.Verify` + 内置公钥接进第 3 步 `MiniGameDownloader`(客户端)与 2a `GameModuleLoader`(服务端)——建议作为各自的小增量补上(本工具已提供可复用的 `ManifestSigner`/`FilesManifest`)。
|
||||
- 微信 WASM 热更合规(§6.6)。
|
||||
@@ -0,0 +1,632 @@
|
||||
# 用 C# 重写 RockPaperScissors + 匹配 + AI 兜底(§9 第 5 步,试金石)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use `- [ ]`.
|
||||
>
|
||||
> **服务端全部可 TDD**(RPS.Core / RPS.Server / Matchmaker / AI 兜底 / 真 Kestrel WS 端到端,`dotnet test`)。**RPS.Client 是 Unity**(标注「[Unity 人工核对]」)。
|
||||
|
||||
**Goal:** 把石头剪刀布从 Lua 重写为 C#:`RPS.Core`(纯规则 `IGameLogic`)+ `RPS.Server`(`IGameServerRoom`,含 AI 出招)+ 服务端**匹配**(按 (gameId,version) 分桶、凑齐 2 人建房、15s 超时 AI 兜底)+ `RPS.Client`(`IGameClient` 表现层,Unity)。验证「写一个新小游戏 = 框架零改动 + 一份 Core 两端共用」,端到端跑通匹配→对局→结算(含 AI 兜底)。
|
||||
|
||||
**Architecture:** `RPS.Core` 用子项目 1 的 `IGameLogic<TState,TInput,TEvent>` + `DeterministicRandom`,纯函数式推进,两端共用、编解码一份。`RPS.Server` 实现 `IGameServerRoom`,经 2a 的 ALC 像 Hello 一样动态加载;AI 座位由 `ctx.Random` 等概率出招(不读对手本回合选择,沿用原 Lua AI 规则)。**匹配器**加在 2b-1 的 `ServerLoop` 前:MatchRequest 入桶,凑齐 `playerCount` 或 15s(N tick)超时→AI 填位→建多人房。AI 玩家是一个 `Send` 为 no-op 的 `IClientConnection`。`RPS.Client` 经第 3 步 `MiniGameHost` 加载,渲染回合 UI。
|
||||
|
||||
**Tech Stack:** C# / RPS.Core+RPS.Server netstandard2.1(两端,ALC 加载)· 子项目 1 契约/协议 + 2a RoomHost/ALC + 2b-1 Gateway/ServerLoop · xUnit + 真 Kestrel/ClientWebSocket e2e · RPS.Client Unity(HybridCLR 热更,第 3 步加载)。
|
||||
|
||||
---
|
||||
|
||||
## 背景与原 RPS 规则(沿用现有 Lua 玩法)
|
||||
|
||||
源规则见 `Client/Assets/RockPaperScissors/script/Server/main.lua`:
|
||||
- **2 人对战**,总时长 60s;每回合:**选择 5s** → **展示 5s** → 下一回合,直到 60s 结束。
|
||||
- 每回合双方各出 rock/paper/scissors;`rock>scissors, paper>rock, scissors>paper`;胜者本回合 +1 分;未出招者超时自动**等概率随机**补出。
|
||||
- 结算:总分高者胜;平分则平局。
|
||||
- **AI 兜底**:匹配超过 15s 未凑齐真人 → 用 AI 补位(`IsAI=true`),AI 等概率随机出招、不读真人本回合选择。
|
||||
- 已完成:子项目 1(契约/协议)、2a(RoomHost/ALC/Hello 加载范式)、2b-1(Gateway/ServerLoop/Session/路由)。RPS.Server 用与 Hello 相同的 ALC + games 目录约定加载。
|
||||
- **无 git**:跳过 git 步骤。工作目录 `D:/UD/AI/AIC#Project`,`dotnet` SDK 10。集成测试前 `bash Server/build-test-games.sh`(将扩展以构建 RPS 夹具)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
RPS 源码(夹具游戏,netstandard2.1,不入 Server.sln,由脚本构建到 TestGames):`Server/games-src/RPS/`
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `RPS.Core/RPS.Core.csproj` | netstandard2.1,引用 Framework.Shared(Private=false) |
|
||||
| `RPS.Core/RpsTypes.cs` | `Choice`/`RpsPhase`/`RpsState`/`RpsInput`/`RpsEvent` |
|
||||
| `RPS.Core/RpsLogic.cs` | `IGameLogic<RpsState,RpsInput,RpsEvent>`:选择/计时/判定/结算 + 编解码 |
|
||||
| `RPS.Server/RPS.Server.csproj` | netstandard2.1,引用 Framework.Shared(Private=false)+RPS.Core |
|
||||
| `RPS.Server/RpsServerRoom.cs` | `IGameServerRoom`:座位映射、AI 出招、广播快照、结算 |
|
||||
|
||||
服务端匹配(加进 Gateway):`Server/Gateway/`
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `Matchmaker.cs` | 按 (gameId,version) 分桶;凑齐 N 或超时 AI 兜底;产出座位名单 |
|
||||
| `ServerLoop.cs`(改) | MatchRequest → 入桶;`DrainAndTick` 驱动匹配;多人/AI 建房 |
|
||||
| `RoomHost`(2a,复用) | 多人房间已支持 |
|
||||
|
||||
客户端(Unity 人工):`Client/Assets/RockPaperScissors/csharp/`(新)
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `RpsGameClient.cs` | `IGameClient`:加载 UI prefab、渲染回合/计时/比分、出招按钮→ctx.Send |
|
||||
|
||||
测试:`Server/Gateway.Tests/` + `Server/Server.Host.Tests/`(RPS.Core 抽测可放任一测试工程或新建)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: RPS.Core 类型与规则(TDD,纯逻辑)
|
||||
|
||||
**Files:** Create `Server/games-src/RPS/RPS.Core/{RPS.Core.csproj,RpsTypes.cs,RpsLogic.cs}`;Test:在 `Server/Server.Host.Tests/` 新增 `RpsLogicTests.cs`(该工程已引用 Framework.Shared;为抽测 RPS.Core,临时给 Server.Host.Tests 加对 RPS.Core 的 ProjectReference —— 仅测试用,RPS.Core 仍不入 sln 主依赖)。
|
||||
|
||||
> 注:为单测 RPS.Core,给 `Server.Host.Tests.csproj` 加 `<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />`。这只让**测试**直接引用 Core 做单元测试;运行时服务端仍通过 ALC 动态加载(与之并不冲突)。
|
||||
|
||||
- [ ] **Step 1: 写 `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>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
|
||||
<Private>false</Private>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `RpsTypes.cs`**
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace RPS.Core
|
||||
{
|
||||
public enum Choice : byte { None = 0, Rock = 1, Paper = 2, Scissors = 3 }
|
||||
public enum RpsPhase : byte { Choosing = 0, Revealing = 1, Finished = 2 }
|
||||
|
||||
public sealed class RpsState
|
||||
{
|
||||
public int Round;
|
||||
public RpsPhase Phase;
|
||||
public float PhaseElapsed; // 当前阶段已用秒
|
||||
public float TotalElapsed; // 全局已用秒
|
||||
public Choice[] Choices = new Choice[2]; // 本回合两座位的选择
|
||||
public int[] Scores = new int[2];
|
||||
public bool[] IsAi = new bool[2];
|
||||
public int Winner = -1; // 结束时:0/1=胜者座位,-1=未结束,2=平局
|
||||
}
|
||||
|
||||
public enum RpsInputKind : byte { Tick = 0, Choose = 1 }
|
||||
|
||||
public struct RpsInput
|
||||
{
|
||||
public RpsInputKind Kind;
|
||||
public int Seat; // Choose 用
|
||||
public Choice Choice;// Choose 用
|
||||
public float Dt; // Tick 用
|
||||
|
||||
public static RpsInput Tick(float dt) => new RpsInput { Kind = RpsInputKind.Tick, Dt = dt };
|
||||
public static RpsInput Choose(int seat, Choice c) => new RpsInput { Kind = RpsInputKind.Choose, Seat = seat, Choice = c };
|
||||
}
|
||||
|
||||
public enum RpsEventKind : byte { RoundResolved = 0, GameFinished = 1 }
|
||||
|
||||
public struct RpsEvent
|
||||
{
|
||||
public RpsEventKind Kind;
|
||||
public int Round;
|
||||
public int RoundWinner; // RoundResolved:0/1 胜者座位,-1 平局
|
||||
public int GameWinner; // GameFinished:0/1 或 2 平局
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 写失败测试 `RpsLogicTests.cs`**(放 `Server/Server.Host.Tests/`)
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using XWorld.Framework;
|
||||
using RPS.Core;
|
||||
|
||||
namespace XWorld.Server.Host.Tests
|
||||
{
|
||||
public class RpsLogicTests
|
||||
{
|
||||
private const float Choose = 5f, Reveal = 5f, Total = 60f;
|
||||
private static RpsState New() => new RpsLogic().CreateInitial(
|
||||
new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 }, new DeterministicRandom(1));
|
||||
|
||||
[Fact]
|
||||
public void Initial_IsChoosingRound1()
|
||||
{
|
||||
var s = New();
|
||||
Assert.Equal(1, s.Round);
|
||||
Assert.Equal(RpsPhase.Choosing, s.Phase);
|
||||
Assert.Equal(Choice.None, s.Choices[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choose_LocksSeatChoice_DuringChoosing()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
Assert.Equal(Choice.Rock, s.Choices[0]);
|
||||
// 二次出招不覆盖
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Paper), null).State;
|
||||
Assert.Equal(Choice.Rock, s.Choices[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChoosingTimeout_AutoRandomsMissing_AndResolves_RockBeatsScissors()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
|
||||
// 推进超过 5s 选择阶段 → 结算本回合
|
||||
var r = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1));
|
||||
s = r.State;
|
||||
Assert.Equal(RpsPhase.Revealing, s.Phase);
|
||||
Assert.Equal(1, s.Scores[0]); // rock 胜 scissors
|
||||
Assert.Equal(0, s.Scores[1]);
|
||||
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.RoundResolved && e.RoundWinner == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tie_NoScore()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Choose(1, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
|
||||
Assert.Equal(0, s.Scores[0]);
|
||||
Assert.Equal(0, s.Scores[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevealTimeout_StartsNextRound()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
|
||||
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State; // → Revealing
|
||||
s = logic.Step(s, RpsInput.Tick(Reveal), new DeterministicRandom(1)).State; // → next round
|
||||
Assert.Equal(2, s.Round);
|
||||
Assert.Equal(RpsPhase.Choosing, s.Phase);
|
||||
Assert.Equal(Choice.None, s.Choices[0]); // 新回合清空
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalTimeout_Finishes_WithWinnerByScore()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
// 座位0 赢一回合,然后把总时间推到 60s
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
|
||||
s = logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(1)).State;
|
||||
var r = logic.Step(s, RpsInput.Tick(Total), new DeterministicRandom(1)); // 越过总时长
|
||||
s = r.State;
|
||||
Assert.Equal(RpsPhase.Finished, s.Phase);
|
||||
Assert.Equal(0, s.Winner); // 座位0 分高
|
||||
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished && e.GameWinner == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_Decode_RoundTrips()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s.Round = 3; s.Phase = RpsPhase.Revealing; s.Scores[0] = 2; s.Scores[1] = 1;
|
||||
s.Choices[0] = Choice.Paper; s.Choices[1] = Choice.Rock;
|
||||
s.TotalElapsed = 12.5f; s.PhaseElapsed = 2.5f;
|
||||
s.IsAi[0] = false; s.IsAi[1] = true; s.Winner = 1;
|
||||
var back = logic.Decode(logic.Encode(s));
|
||||
Assert.Equal(3, back.Round);
|
||||
Assert.Equal(RpsPhase.Revealing, back.Phase);
|
||||
Assert.Equal(2, back.Scores[0]);
|
||||
Assert.Equal(Choice.Paper, back.Choices[0]);
|
||||
Assert.Equal(Choice.Rock, back.Choices[1]);
|
||||
Assert.Equal(12.5f, back.TotalElapsed, 3);
|
||||
Assert.Equal(2.5f, back.PhaseElapsed, 3);
|
||||
Assert.False(back.IsAi[0]);
|
||||
Assert.True(back.IsAi[1]);
|
||||
Assert.Equal(1, back.Winner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalTimeout_DuringRevealing_Finishes()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
var s = New();
|
||||
s = logic.Step(s, RpsInput.Choose(0, Choice.Rock), null).State;
|
||||
s = logic.Step(s, RpsInput.Choose(1, Choice.Scissors), null).State;
|
||||
s = logic.Step(s, RpsInput.Tick(5f), new DeterministicRandom(1)).State; // → Revealing, 座位0 得分
|
||||
Assert.Equal(RpsPhase.Revealing, s.Phase);
|
||||
s.TotalElapsed = 58f;
|
||||
var r = logic.Step(s, RpsInput.Tick(2f), new DeterministicRandom(1)); // total→60 → Finish
|
||||
s = r.State;
|
||||
Assert.Equal(RpsPhase.Finished, s.Phase);
|
||||
Assert.Equal(0, s.Winner);
|
||||
Assert.Contains(r.Events, e => e.Kind == RpsEventKind.GameFinished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameSeed_AutoRandom_IsDeterministic()
|
||||
{
|
||||
var logic = new RpsLogic();
|
||||
RpsState Run(ulong seed)
|
||||
{
|
||||
var s = New();
|
||||
// 双方都不出招,靠超时自动随机
|
||||
return logic.Step(s, RpsInput.Tick(Choose), new DeterministicRandom(seed)).State;
|
||||
}
|
||||
var a = Run(7); var b = Run(7);
|
||||
Assert.Equal(a.Choices[0], b.Choices[0]);
|
||||
Assert.Equal(a.Choices[1], b.Choices[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** Run(先给 Server.Host.Tests 加 RPS.Core 的 ProjectReference)`bash Server/build-test-games.sh && dotnet test Server/Server.sln --filter RpsLogicTests` → RED(RpsLogic 不存在)。
|
||||
|
||||
- [ ] **Step 5: 写 `RpsLogic.cs`**
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace RPS.Core
|
||||
{
|
||||
public sealed class RpsLogic : IGameLogic<RpsState, RpsInput, RpsEvent>
|
||||
{
|
||||
public const float ChooseSeconds = 5f;
|
||||
public const float RevealSeconds = 5f;
|
||||
public const float TotalSeconds = 60f;
|
||||
|
||||
public RpsState CreateInitial(RoomConfig config, IRandom random)
|
||||
{
|
||||
var s = new RpsState { Round = 1, Phase = RpsPhase.Choosing };
|
||||
return s;
|
||||
}
|
||||
|
||||
public StepResult<RpsState, RpsEvent> Step(RpsState state, RpsInput input, IRandom random)
|
||||
{
|
||||
var events = new List<RpsEvent>();
|
||||
if (state.Phase == RpsPhase.Finished) return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
|
||||
if (input.Kind == RpsInputKind.Choose)
|
||||
{
|
||||
if (state.Phase == RpsPhase.Choosing && state.Choices[input.Seat] == Choice.None
|
||||
&& input.Choice != Choice.None)
|
||||
state.Choices[input.Seat] = input.Choice;
|
||||
return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
}
|
||||
|
||||
// Tick
|
||||
state.PhaseElapsed += input.Dt;
|
||||
state.TotalElapsed += input.Dt;
|
||||
|
||||
if (state.Phase == RpsPhase.Choosing && state.PhaseElapsed >= ChooseSeconds)
|
||||
{
|
||||
ResolveRound(state, random, events);
|
||||
state.Phase = RpsPhase.Revealing;
|
||||
state.PhaseElapsed = 0f;
|
||||
}
|
||||
else if (state.Phase == RpsPhase.Revealing && state.PhaseElapsed >= RevealSeconds)
|
||||
{
|
||||
state.Round++;
|
||||
state.Phase = RpsPhase.Choosing;
|
||||
state.PhaseElapsed = 0f;
|
||||
state.Choices[0] = Choice.None; state.Choices[1] = Choice.None;
|
||||
}
|
||||
|
||||
if (state.Phase != RpsPhase.Finished && state.TotalElapsed >= TotalSeconds)
|
||||
Finish(state, events);
|
||||
|
||||
return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
}
|
||||
|
||||
private static readonly Choice[] All = { Choice.Rock, Choice.Paper, Choice.Scissors };
|
||||
|
||||
private void ResolveRound(RpsState s, IRandom rng, List<RpsEvent> events)
|
||||
{
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (s.Choices[i] == Choice.None)
|
||||
s.Choices[i] = All[rng.Next(3)]; // 等概率自动随机
|
||||
|
||||
int winner = WinnerOf(s.Choices[0], s.Choices[1]); // -1 平局,0/1 胜者座位
|
||||
if (winner >= 0) s.Scores[winner]++;
|
||||
events.Add(new RpsEvent { Kind = RpsEventKind.RoundResolved, Round = s.Round, RoundWinner = winner });
|
||||
}
|
||||
|
||||
// 0 胜返回 0;1 胜返回 1;平返回 -1
|
||||
private static int WinnerOf(Choice a, Choice b)
|
||||
{
|
||||
if (a == b) return -1;
|
||||
bool aWins = (a == Choice.Rock && b == Choice.Scissors)
|
||||
|| (a == Choice.Paper && b == Choice.Rock)
|
||||
|| (a == Choice.Scissors && b == Choice.Paper);
|
||||
return aWins ? 0 : 1;
|
||||
}
|
||||
|
||||
private void Finish(RpsState s, List<RpsEvent> events)
|
||||
{
|
||||
s.Phase = RpsPhase.Finished;
|
||||
s.Winner = s.Scores[0] == s.Scores[1] ? 2 : (s.Scores[0] > s.Scores[1] ? 0 : 1);
|
||||
events.Add(new RpsEvent { Kind = RpsEventKind.GameFinished, GameWinner = s.Winner });
|
||||
}
|
||||
|
||||
public byte[] Encode(RpsState s)
|
||||
{
|
||||
var w = new PacketWriter();
|
||||
w.WriteVarInt(s.Round);
|
||||
w.WriteByte((byte)s.Phase);
|
||||
w.WriteSingle(s.PhaseElapsed);
|
||||
w.WriteSingle(s.TotalElapsed);
|
||||
w.WriteByte((byte)s.Choices[0]); w.WriteByte((byte)s.Choices[1]);
|
||||
w.WriteVarInt(s.Scores[0]); w.WriteVarInt(s.Scores[1]);
|
||||
w.WriteBool(s.IsAi[0]); w.WriteBool(s.IsAi[1]);
|
||||
w.WriteVarInt(s.Winner);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
public RpsState Decode(byte[] data)
|
||||
{
|
||||
var r = new PacketReader(data);
|
||||
var s = new RpsState
|
||||
{
|
||||
Round = r.ReadVarInt(),
|
||||
Phase = (RpsPhase)r.ReadByte(),
|
||||
PhaseElapsed = r.ReadSingle(),
|
||||
TotalElapsed = r.ReadSingle(),
|
||||
};
|
||||
s.Choices[0] = (Choice)r.ReadByte(); s.Choices[1] = (Choice)r.ReadByte();
|
||||
s.Scores[0] = r.ReadVarInt(); s.Scores[1] = r.ReadVarInt();
|
||||
s.IsAi[0] = r.ReadBool(); s.IsAi[1] = r.ReadBool();
|
||||
s.Winner = r.ReadVarInt();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6:** Run filter → GREEN(8 用例)。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RPS.Server 房间逻辑(TDD via 直接驱动)
|
||||
|
||||
**Files:** Create `Server/games-src/RPS/RPS.Server/{RPS.Server.csproj,RpsServerRoom.cs}`;Test `Server/Server.Host.Tests/RpsServerRoomTests.cs`(直接 new RpsServerRoom 驱动,用 2a 的 RoomCtx/能力 + 假 IRoomOutput)。
|
||||
|
||||
> 座位约定:`OnRoomStart` 收到 `IReadOnlyList<PlayerInfo>`,按顺序映射 playerId→座位 0/1,记录 `IsAI`。`OnMessage`:解出 Choice,`Core.Step(Choose)`。`OnTick`:Choosing 阶段给 AI 座位 `ctx.Random` 出招;`Core.Step(Tick,dt)`;广播快照(opcode=1,payload=Core.Encode);Finished→`ctx.EndRoom`。
|
||||
|
||||
- [ ] **Step 1: 写 `RPS.Server.csproj`**(netstandard2.1,引用 Framework.Shared `Private=false` + RPS.Core)。
|
||||
- [ ] **Step 2: 写失败测试 `RpsServerRoomTests.cs`**(要点):
|
||||
- 2 个真人座位:OnRoomStart→OnMessage 各出招→多次 OnTick(5f) 推进→广播快照计数>0、座位分按规则更新;推进到 60s→房间结束(ctx EndRoom 触发)。
|
||||
- 1 真人 + 1 AI 座位:OnTick 时 AI 自动出招,对局能推进并结算。
|
||||
- (断言通过解码广播的快照 payload(RpsLogic.Decode)读取 Round/Scores/Phase。)
|
||||
- [ ] **Step 3: 写 `RpsServerRoom.cs`**
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
using RPS.Core;
|
||||
|
||||
namespace RPS.Server
|
||||
{
|
||||
public sealed class RpsServerRoom : IGameServerRoom
|
||||
{
|
||||
public const ushort SnapshotOpcode = 1;
|
||||
public const ushort ChoiceOpcode = 1; // 客户端出招消息 opcode
|
||||
|
||||
private readonly RpsLogic _logic = new RpsLogic();
|
||||
private IRoomCtx _ctx;
|
||||
private RpsState _state;
|
||||
private readonly Dictionary<int, int> _seatOf = new Dictionary<int, int>(); // playerId->seat
|
||||
private bool _ended;
|
||||
|
||||
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_state = _logic.CreateInitial(new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = players.Count }, ctx.Random);
|
||||
for (int i = 0; i < players.Count && i < 2; i++)
|
||||
{
|
||||
_seatOf[players[i].PlayerId] = i;
|
||||
_state.IsAi[i] = players[i].IsAI;
|
||||
}
|
||||
Broadcast();
|
||||
}
|
||||
|
||||
public void OnMessage(int playerId, NetMessage message)
|
||||
{
|
||||
if (message.Opcode != ChoiceOpcode) return;
|
||||
if (!_seatOf.TryGetValue(playerId, out int seat)) return;
|
||||
var r = new PacketReader(message.Payload);
|
||||
var choice = (Choice)r.ReadByte();
|
||||
_state = _logic.Step(_state, RpsInput.Choose(seat, choice), _ctx.Random).State;
|
||||
}
|
||||
|
||||
public void OnTick(float dt)
|
||||
{
|
||||
if (_ended) return;
|
||||
// Choosing 阶段:AI 座位等概率出招(不读对手),仅在尚未出招时
|
||||
if (_state.Phase == RpsPhase.Choosing)
|
||||
for (int seat = 0; seat < 2; seat++)
|
||||
if (_state.IsAi[seat] && _state.Choices[seat] == Choice.None)
|
||||
{
|
||||
var c = new[] { Choice.Rock, Choice.Paper, Choice.Scissors }[_ctx.Random.Next(3)];
|
||||
_state = _logic.Step(_state, RpsInput.Choose(seat, c), _ctx.Random).State;
|
||||
}
|
||||
|
||||
var res = _logic.Step(_state, RpsInput.Tick(dt), _ctx.Random);
|
||||
_state = res.State;
|
||||
Broadcast();
|
||||
|
||||
if (_state.Phase == RpsPhase.Finished)
|
||||
{
|
||||
_ended = true;
|
||||
_ctx.Logger.Info($"rps finished, winner seat={_state.Winner}, scores {_state.Scores[0]}:{_state.Scores[1]}");
|
||||
_ctx.EndRoom();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRoomEnd() => _ctx.Logger.Info("rps room end");
|
||||
|
||||
private void Broadcast() => _ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RPS 夹具构建(扩展 build-test-games.sh)
|
||||
|
||||
**Files:** Modify `Server/build-test-games.sh`(追加构建 RPS 到 `TestGames/rps/1/`)。
|
||||
|
||||
- [ ] **Step 1:** 在脚本里追加一个 `build_rps` 函数:`dotnet build RPS.Server.csproj -c Release --no-incremental` → 拷 `RPS.Core.dll`/`RPS.Server.dll` 到 `Server/Server.Host.Tests/TestGames/rps/1/`,写 `game.json`(gameId=rps,version=1,serverAssembly=RPS.Server.dll,serverEntryType=RPS.Server.RpsServerRoom,playerCount=2,tickRateHz=10),断言不带 Framework.Shared.dll。
|
||||
- [ ] **Step 2:** Run `bash Server/build-test-games.sh` → 确认 `TestGames/rps/1/` 三件套齐全、无 Framework.Shared.dll。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 通过 ALC 加载 RPS(集成 TDD)
|
||||
|
||||
**Files:** Test `Server/Server.Host.Tests/RpsModuleLoadTests.cs`。
|
||||
|
||||
- [ ] **Step 1:** 写测试:用 2a 的 `GameModuleLoader().Load(TestGames.Root, "rps", 1)` 加载,`CreateRoom()` 得到 `IGameServerRoom`,OnRoomStart(2 座位,其一 IsAI)+ 多次 OnTick→广播快照可解码、能推进到结算。证明「框架零改动加载新游戏」(与 Hello 同范式)。
|
||||
- [ ] **Step 2:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 匹配器 Matchmaker(TDD)
|
||||
|
||||
**Files:** Create `Server/Gateway/Matchmaker.cs`;Test `Server/Gateway.Tests/MatchmakerTests.cs`。
|
||||
|
||||
> 纯逻辑:按 (gameId,version) 分桶,记录等待玩家与入桶 tick;`Poll(currentTick)` 返回已成形的对局(凑齐 N,或超时用 AI 补足 N)。不碰网络/房间。
|
||||
|
||||
- [ ] **Step 1: 失败测试**(要点):
|
||||
- `Enqueue(pid, gameId, ver, playerCount, tick)`;两次 Enqueue 同桶且 playerCount=2 → `Poll` 返回一个含两真人的 match。
|
||||
- 单人入桶,`Poll(tick+timeout+1)` → 返回含 1 真人 + 1 AI(IsAI=true,pid 为负)的 match。
|
||||
- 未满且未超时 → `Poll` 返回空。
|
||||
- [ ] **Step 2: 实现**
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Server.Gateway
|
||||
{
|
||||
public sealed class Matchmaker
|
||||
{
|
||||
public sealed class Seat { public int PlayerId; public bool IsAi; }
|
||||
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
|
||||
|
||||
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
|
||||
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
|
||||
|
||||
private readonly long _timeoutTicks;
|
||||
private int _aiSeq;
|
||||
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
|
||||
|
||||
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
|
||||
private static string Key(string g, int v) => $"{g}@{v}";
|
||||
|
||||
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
|
||||
{
|
||||
string k = Key(gameId, version);
|
||||
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
|
||||
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
|
||||
}
|
||||
|
||||
// 返回本次可成形的对局(凑齐或超时 AI 补足)
|
||||
public IReadOnlyList<Match> Poll(long currentTick)
|
||||
{
|
||||
List<Match> formed = null;
|
||||
foreach (var kv in _buckets)
|
||||
{
|
||||
var b = kv.Value;
|
||||
string[] gv = kv.Key.Split('@');
|
||||
while (b.Waiters.Count > 0 &&
|
||||
(b.Waiters.Count >= b.PlayerCount ||
|
||||
currentTick - b.Waiters[0].EnqueuedTick > _timeoutTicks))
|
||||
{
|
||||
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
|
||||
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
|
||||
for (int i = 0; i < take; i++)
|
||||
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].PlayerId, IsAi = false });
|
||||
b.Waiters.RemoveRange(0, take);
|
||||
while (m.Seats.Count < b.PlayerCount)
|
||||
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
|
||||
(formed ??= new List<Match>()).Add(m);
|
||||
}
|
||||
}
|
||||
return formed ?? (IReadOnlyList<Match>)System.Array.Empty<Match>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 3:** GREEN。
|
||||
|
||||
---
|
||||
|
||||
## Task 6: ServerLoop 接入匹配 + 多人/AI 建房(TDD)
|
||||
|
||||
**Files:** Modify `Server/Gateway/ServerLoop.cs`;Test `Server/Gateway.Tests/ServerLoopMatchmakingTests.cs`。
|
||||
|
||||
> 改动:`HandleFramework`(MatchRequest) 不再立刻 `CreateRoomFor`,而是 `_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick)`(playerCount 暂从一个 gameId→count 映射/或固定按 manifest;本步骤可用「已知 rps=2、hello=1」的简单解析或读 game.json)。`DrainAndTick` 末尾 `_matchmaker.Poll(_tick)`,对每个 Match 调新的 `CreateRoomForSeats(match)`:真人座位绑 session.RoomId,AI 座位无 session;用 `RoomOutputSink(roomId, realPids, _sessions)`(只对真人广播,AI 由 GetConnection=null 跳过);reply MatchFound 给真人座位。playerCount 来源:从 `games/{id}/{ver}/game.json` 读 `playerCount`(宿主可加一个轻量读取,或 Matchmaker 入桶时由调用方传入——本步骤 ServerLoop 维护 `gameId->playerCount` 小映射并允许从 manifest 懒加载)。
|
||||
|
||||
- [ ] **Step 1: 失败测试**(用 RPS 夹具 + FakeConnection):
|
||||
- 两个 FakeConnection 各发 MatchRequest(rps,1) → 若干 DrainAndTick → 两者都收到 MatchFound(同一 roomId、含 2 名玩家)→ 各自发出招 → 推进至结算 → 两者都收到 RoomEnd。
|
||||
- 单个 FakeConnection 发 MatchRequest(rps,1) → DrainAndTick 越过超时 → 收到 MatchFound(含 1 真人 + 1 AI 标记)→ AI 自动出招 → 推进至结算 → 收到 RoomEnd。
|
||||
- [ ] **Step 2:** 实现改动(Matchmaker 注入、CreateRoomForSeats、playerCount 解析、Poll 接入 DrainAndTick)。保留 2b-1 既有用例全绿(单人 hello 仍走「凑齐 1 即建房」——hello playerCount=1,Matchmaker 立即成形)。
|
||||
- [ ] **Step 3:** GREEN,且 2b-1 既有 Gateway 测试不回归。
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 端到端(真 Kestrel WS:2 真人 + AI 兜底)
|
||||
|
||||
**Files:** Test `Server/Gateway.Tests/RpsEndToEndTests.cs`。
|
||||
|
||||
- [ ] **Step 1:** 写集成测试(真 Kestrel + 2 个 ClientWebSocket):
|
||||
- 两客户端连接、各发 MatchRequest(rps,1) → 各收 MatchFound → 各发出招(ChoiceOpcode Game 帧)→ 持续收快照 → 最终收 RoomEnd。断言双方进入同一房间、对局推进、收到结束。
|
||||
- 单客户端 + 大 tick 间隔让超时触发 → 收 MatchFound(含 AI)→ 收快照(AI 在出招)→ 收 RoomEnd。
|
||||
- tick 间隔选择:为缩短 60s 对局,测试用**较大 dt**(如每 tick dt=10f,6 个 tick 即结束)或把 RPS 总时长经 RoomConfig 注入(本计划用大 dt 推进,避免改 Core 常量)。
|
||||
- [ ] **Step 2:** GREEN,两次运行无抖动。
|
||||
|
||||
> dt 说明:服务端 tick 循环按 `tickIntervalMs` 真实节奏调 `TickAll(dt)`,但 dt 是逻辑步长。测试可设较大 tickIntervalMs + 较大 dt 让 RPS 的 60s 在数个 tick 内走完(RPS.Core 用累积秒判定,纯逻辑,与真实墙钟解耦)。
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 收尾——全量验证(服务端)
|
||||
|
||||
- [ ] **Step 1:** `bash Server/build-test-games.sh && dotnet test Server/Server.sln` 全绿(94 + RPS:RpsLogic 8 + RpsServerRoom + RpsModuleLoad + Matchmaker + ServerLoopMatchmaking + RpsEndToEnd)。
|
||||
- [ ] **Step 2:** `dotnet build Server/Server.sln -v minimal` → 0 error / 0 warning。
|
||||
|
||||
---
|
||||
|
||||
## Task 9: [Unity 人工核对] RPS.Client 表现层
|
||||
|
||||
**Files:** Create `Client/Assets/RockPaperScissors/csharp/RpsGameClient.cs`(namespace `RPS.Client`,热更,引用 Framework.Shared + RPS.Core)。
|
||||
|
||||
> 实现 `IGameClient`:`OnEnter(ctx)` 加载 UI prefab(`ctx.Assets.Load("Assets/Game/Art/UI/Prefab/UI_RockPaperScissors.prefab")`)、挂到 Main;`OnNetMessage(msg)` 解码 `RpsLogic.Decode` 渲染回合/计时/比分;三个出招按钮 → `ctx.Send(new NetMessage(RpsServerRoom.ChoiceOpcode, payload))`;`OnUpdate(dt)` 刷新倒计时;`OnExit` 清理 UI。具体代码按现有 RPS Lua UI 结构(`UI_RockPaperScissors.prefab` 既有)改写为 C#,用第 3 步 `MiniGameHost` 加载。
|
||||
|
||||
- [ ] **Step 1:** 写 `RpsGameClient.cs`(参照原 `RockPaperScissors/script/Client/main.lua` 的 UI 节点结构与渲染逻辑,转为 C# + `IGameClient`)。
|
||||
- [ ] **Step 2: [Unity 人工核对]** 用第 3 步 `MiniGameHost.EnterGame(cdn, "rps", 1, sock)` 加载,连接本步骤服务端,端到端验证:匹配(或 AI 兜底)→ 出招 → 看到对手出招/比分/倒计时 → 结算界面 → 返回大厅。验证「写一个新小游戏 = 框架零改动」。
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
- [ ] **服务端(可 TDD)全绿**:RPS.Core 规则/编解码/确定性、RPS.Server 房间/AI、ALC 加载 RPS、Matchmaker 分桶/超时 AI、ServerLoop 匹配建房、真 WS 2 人 + AI 兜底端到端。`dotnet build` 0 警告。
|
||||
- [ ] 「框架零改动」验证:RPS 经与 Hello 相同的 ALC + games 约定加载,框架(Framework.Shared/RoomHost/Gateway)未为 RPS 改任何接口(仅 ServerLoop 接入通用 Matchmaker,非 RPS 专属)。
|
||||
- [ ] 一份 Core 两端共用:RPS.Core 同时被服务端(ALC 加载)与客户端(HybridCLR 加载)使用,编解码一份。
|
||||
- [ ] [Unity 人工核对] RPS.Client 端到端跑通匹配→对局→结算→返回大厅。
|
||||
|
||||
## 不在本步骤范围 / 风险
|
||||
|
||||
- 真实结算落库/奖励发放(设计 §3.3 OnRoomEnd 落库)—— 本步骤 RPS.Server 只产出胜负,奖励接口接 2a `IStorage`/后续持久化。
|
||||
- 断线重连在对局中的 RPS 体验(2b-1 已有会话重连;RPS 状态由服务端权威,重连后下个快照即校正)。
|
||||
- 微信 WASM 热更合规(§6.6)。
|
||||
- RPS.Client 的 UI 细节按既有 prefab 结构实现(Unity 人工)。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
# 探测 IP 可配置化 + 移除组播 实施计划
|
||||
|
||||
> **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:** 删除实测无效的组播发现代码;开发机探测 IP 从代码写死改为设备本地配置 `persistentDataPath/dev_server.txt`,底包发现失败时弹 IMGUI 输入框录入并验证后持久化。
|
||||
|
||||
**Architecture:** 三个小单元:`DevServerConfig`(Base,dev_server.txt 读写)、`DevServerPrompt`(Base,纯 IMGUI 三态输入框)、`LoadDll` 协程编排两轮发现+弹框循环。`LocalCdnDiscovery.Discover` 加 `extraProbeIp` 参数(后台线程不能碰 persistentDataPath,配置由主线程读好传入);热更层 `DevServerDiscovery` 内联同语义配置读取(XWorld.Link 未引用 Base 程序集,沿用"内联+两边同步"惯例)。
|
||||
|
||||
**Tech Stack:** C#/.NET(netstandard2.1 共享协议)、Unity 客户端(IMGUI)、xUnit 服务端测试。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-06-dev-server-config-design.md`
|
||||
|
||||
**注意:** 项目根 `.git` 是空目录、非有效 git 仓库,省略 commit 步骤,以"运行测试/目视核对"作为每任务收尾。
|
||||
|
||||
**关键背景(执行者必读):**
|
||||
- 前一个特性(组播叠加,plan `2026-07-06-multicast-discovery.md`)已实施但实测跨网段不通,本计划将其代码**全部删除**。删除时以"还原并改进"为准——不是逐字回滚,注释要更新为新语义(配置文件而非写死 IP)。
|
||||
- `Server\Framework.Shared\Framework.Shared.csproj` 直接编译 `Client\Assets\Framework\Shared\**\*.cs`,共享协议常量改一处两边生效。**删 `DevDiscovery.MulticastGroup` 前必须先删掉它的最后一个使用点(`DevServerDiscovery.cs`),否则 Unity 编译会断**——任务顺序已按此安排,勿调换。
|
||||
- `Client\Assets\Base\**` 是 AOT 底包程序集(Base.asmdef),不能引用热更程序集;`Client\Assets\Script\**` 是热更程序集(XWorld.Link.asmdef),其引用列表**没有 Base**,所以热更层也引用不到 Base 的新类。
|
||||
- `Application.persistentDataPath` 只能在 Unity 主线程访问;两个发现器都跑在后台线程,配置读取必须在主线程完成(参数传入/构造函数存字段)。
|
||||
- 代码注释用中文、全角标点;网络失败静默吞掉是本模块的既定风格。
|
||||
- 新建 .cs 文件无需手写 .meta,Unity 编辑器打开时自动生成。
|
||||
- 测试命令在仓库根执行,路径含 `#`,bash 中必须加引号:`cd "/d/UD/AI/AIC#Project"`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 删除服务端组播代码与测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `Server\Gateway\DevDiscoveryResponder.cs`
|
||||
- Modify: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`
|
||||
|
||||
(共享常量 `DevDiscovery.MulticastGroup` 此任务**不删**——客户端 `DevServerDiscovery.cs` 还在用,Task 4 收尾时删。)
|
||||
|
||||
- [ ] **Step 1: 删响应器的组播 join**
|
||||
|
||||
`Server\Gateway\DevDiscoveryResponder.cs` 三处:
|
||||
|
||||
1. 删文件头 `using System.Net.NetworkInformation;`
|
||||
2. 删构造函数中这一行:
|
||||
|
||||
```csharp
|
||||
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
|
||||
```
|
||||
|
||||
3. 删整个 `JoinMulticastOnAllNics` 私有静态方法(含其上三行中文注释,从 `// 逐块 Up 状态非回环 IPv4 网卡加入组播组` 到方法闭括号)。
|
||||
|
||||
删后构造函数应恢复为:
|
||||
|
||||
```csharp
|
||||
_udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
|
||||
Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删组播测试**
|
||||
|
||||
`Server\Gateway.Tests\DevDiscoveryResponderTests.cs`:删整个 `MulticastQuery_ReceivesReply` 测试方法(`[Fact]` 到方法闭括号)。原有 3 条测试(`ValidQuery_ReceivesReply_WithAdvertisedAddresses`、`WrongToken_NoReply`、`GarbagePacket_Ignored_LoopStillAnswers`)与 `QueryAsync` 辅助方法不动。
|
||||
|
||||
- [ ] **Step 3: 跑 Gateway 测试确认绿**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests
|
||||
```
|
||||
|
||||
预期:全部通过,总数比之前少 1(42 条)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 新建 DevServerConfig
|
||||
|
||||
**Files:**
|
||||
- Create: `Client\Assets\Base\DevServerConfig.cs`
|
||||
|
||||
无自动化测试(Unity 客户端代码);正确性靠 Task 7 编辑器/真机验收。
|
||||
|
||||
- [ ] **Step 1: 写文件**
|
||||
|
||||
完整内容:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using UnityEngine;
|
||||
|
||||
// 内网联调配置:开发机探测 IP 的本地持久化(persistentDataPath/dev_server.txt,单行 IPv4)。
|
||||
// 跨网段时广播/组播都出不了网段(路由器不转发),只能向已知开发机 IP 单播——该 IP 由本文件持久化,
|
||||
// 首次由 DevServerPrompt 弹框录入并验证成功后写入(见 LoadDll.CoLocalDiscoverThenDownload)。
|
||||
// 注意:Application.persistentDataPath 只能在主线程访问,故 Load/Save 只能在主线程调;
|
||||
// 发现线程(后台)需要的值由调用方在主线程读好传入。
|
||||
// 热更层 DevServerDiscovery 内联了同语义的读取(XWorld.Link 未引用 Base 程序集),改格式时两边须同步。
|
||||
public static class DevServerConfig
|
||||
{
|
||||
public static string FilePath => Path.Combine(Application.persistentDataPath, "dev_server.txt");
|
||||
|
||||
// 读配置:文件不存在/IO 异常/内容不是合法 IPv4 → null(静默,等同"无配置")。
|
||||
public static string Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = FilePath;
|
||||
if (!File.Exists(path)) return null;
|
||||
string line = File.ReadAllText(path).Trim();
|
||||
if (line.Length == 0) return null;
|
||||
if (!IPAddress.TryParse(line, out IPAddress ip)) return null;
|
||||
if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4
|
||||
return ip.ToString();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 写配置:单行覆盖;IO 异常静默吞(写不进只是下次还弹框,不致命)。
|
||||
public static void Save(string ip)
|
||||
{
|
||||
try { File.WriteAllText(FilePath, ip ?? ""); }
|
||||
catch (Exception) { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 目视核对**
|
||||
|
||||
所有类型(`Path`/`File`/`IPAddress`/`AddressFamily`/`Application`)均被文件顶部 using 覆盖;类在全局命名空间(与 `LocalCdnDiscovery` 一致)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: LocalCdnDiscovery 删组播、加 extraProbeIp 参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Base\LocalCdnDiscovery.cs`
|
||||
|
||||
- [ ] **Step 1: 删内联组播常量**
|
||||
|
||||
删这一行(const 块中):
|
||||
|
||||
```csharp
|
||||
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删 DevExtraProbeIps 整块**
|
||||
|
||||
删字段及其上方全部 4 行注释(从 `// 跨网段内网联调用:` 到 `private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。
|
||||
|
||||
- [ ] **Step 3: 改 Discover 签名与方法体**
|
||||
|
||||
方法头注释与签名改为:
|
||||
|
||||
```csharp
|
||||
// 阻塞式发现,须在后台线程调用(会最多阻塞 durationMs)。
|
||||
// 在窗口内周期重发查询(向广播 + 127.0.0.1 单播),收到首个合法应答即返回其 ResourceBaseUrl(CDN 根);超时返回 null。
|
||||
// extraProbeIp:额外单播探测目标(跨网段联调用,来自 dev_server.txt 或弹框输入,见 DevServerConfig/DevServerPrompt)。
|
||||
// 广播/定向广播不被路由器转发(RFC 2644),组播亦已实测不通(路由器未开组播路由),跨网段只能靠单播已知 IP。
|
||||
// 注意:本方法跑在后台线程,不能碰 Unity 主线程 API(如 persistentDataPath),配置由调用方在主线程读好传入。
|
||||
public static string Discover(string token, int durationMs = 1500, string extraProbeIp = null)
|
||||
```
|
||||
|
||||
方法体内四处改动:
|
||||
|
||||
1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后):
|
||||
|
||||
```csharp
|
||||
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
|
||||
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
|
||||
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
|
||||
```
|
||||
|
||||
2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为:
|
||||
|
||||
```csharp
|
||||
if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra))
|
||||
targetList.Add(new IPEndPoint(extra, Port));
|
||||
```
|
||||
|
||||
3. 删组播端点/网卡列表构建及日志(共 5 行,targets 的 Debug.Log 之后):
|
||||
|
||||
```csharp
|
||||
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
|
||||
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
|
||||
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(MulticastGroup), Port);
|
||||
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
|
||||
UnityEngine.Debug.Log("[Discovery] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
|
||||
```
|
||||
|
||||
4. 删重发块内的逐网卡组播发送与零网卡兜底(targets foreach 之后、`nextSend = ...` 之前的 13 行):
|
||||
|
||||
```csharp
|
||||
foreach (IPAddress nicAddr in multicastNics)
|
||||
{
|
||||
try
|
||||
{
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
|
||||
udp.Send(query, query.Length, multicastEp);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (multicastNics.Count == 0)
|
||||
{
|
||||
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 catch 注释、删辅助方法**
|
||||
|
||||
1. `catch (Exception)` 内注释改为:
|
||||
|
||||
```csharp
|
||||
// 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1)
|
||||
```
|
||||
|
||||
2. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释 `// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。`)。`GetDirectedBroadcasts()`、`IsPrivateV4`、编解码方法全部不动。
|
||||
|
||||
- [ ] **Step 5: 目视核对**
|
||||
|
||||
全文不再出现 `Multicast`/`multicast`/`DevExtraProbeIps`/`192.168.130.40`;大括号配平;`extraProbeIp` 是唯一新增参数且带默认值(既有调用 `Discover("xw-dev", 1500)` 不受影响)。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: DevServerDiscovery 删组播、内联配置读取;删共享组播常量
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
|
||||
- Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`
|
||||
|
||||
- [ ] **Step 1: 替换 DevExtraProbeIps 为配置字段**
|
||||
|
||||
删字段及其上方 4 行注释(从 `// 跨网段内网联调用:` 到 `private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。
|
||||
|
||||
字段区加一行(`private readonly string _token;` 之后):
|
||||
|
||||
```csharp
|
||||
private readonly string _extraProbeIp;
|
||||
```
|
||||
|
||||
构造函数改为:
|
||||
|
||||
```csharp
|
||||
public DevServerDiscovery(string token)
|
||||
{
|
||||
_token = token;
|
||||
// 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由),
|
||||
// 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框
|
||||
// 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问,
|
||||
// 故在主线程构造时读好存字段,Run(后台线程)只用字段。
|
||||
_extraProbeIp = ReadConfiguredProbeIp();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 内联配置读取方法**
|
||||
|
||||
在 `Stop()` 方法之后、类闭括号之前新增:
|
||||
|
||||
```csharp
|
||||
// 读 dev_server.txt 首行并校验为 IPv4,失败一律 null。与 Base 层 DevServerConfig.Load() 语义一致——
|
||||
// XWorld.Link 未引用 Base 程序集,按本项目"内联+两边同步"惯例复制最小实现,改格式时两边须同步。
|
||||
private static string ReadConfiguredProbeIp()
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = System.IO.Path.Combine(Application.persistentDataPath, "dev_server.txt");
|
||||
if (!System.IO.File.Exists(path)) return null;
|
||||
string line = System.IO.File.ReadAllText(path).Trim();
|
||||
if (line.Length == 0) return null;
|
||||
if (!IPAddress.TryParse(line, out IPAddress ip)) return null;
|
||||
if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4
|
||||
return ip.ToString();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`System.IO` 用全限定,避免新增 using;`Application`/`IPAddress`/`AddressFamily`/`Exception` 均已有 using 覆盖。)
|
||||
|
||||
- [ ] **Step 3: Run() 删组播、改 targets**
|
||||
|
||||
1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后):
|
||||
|
||||
```csharp
|
||||
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
|
||||
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
|
||||
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
|
||||
```
|
||||
|
||||
2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为:
|
||||
|
||||
```csharp
|
||||
if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra))
|
||||
targetList.Add(new IPEndPoint(extra, DevDiscovery.Port));
|
||||
```
|
||||
|
||||
3. 删组播端点/网卡列表构建及日志(targets 的 Debug.Log 之后共 5 行,内容同 Task 3 Step 3 第 3 点,仅 `DevDiscovery.MulticastGroup`/`DevDiscovery.Port`/`[DevTest]` 之别)。
|
||||
|
||||
4. 删重发块内逐网卡组播发送与零网卡兜底(13 行,内容同 Task 3 Step 3 第 4 点)。
|
||||
|
||||
5. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释)。
|
||||
|
||||
- [ ] **Step 4: 删共享协议组播常量**
|
||||
|
||||
`Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`:删 `MulticastGroup` 常量及其上两行注释:
|
||||
|
||||
```csharp
|
||||
// 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由;
|
||||
// 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。
|
||||
public const string MulticastGroup = "239.192.48.92";
|
||||
```
|
||||
|
||||
删后 `DevDiscovery` 类只剩 `Port`/`ProtoVersion`/`Magic`/`TypeQuery`/`TypeReply`。
|
||||
|
||||
- [ ] **Step 5: 全仓库确认无残留引用**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && grep -rn "MulticastGroup\|239.192.48.92" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests
|
||||
```
|
||||
|
||||
预期:无输出(docs/ 里的历史记录不算)。
|
||||
|
||||
- [ ] **Step 6: 服务端全量回归**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
||||
```
|
||||
|
||||
预期:全部通过(172 条:比组播版少 1 条测试)。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 新建 DevServerPrompt
|
||||
|
||||
**Files:**
|
||||
- Create: `Client\Assets\Base\DevServerPrompt.cs`
|
||||
|
||||
- [ ] **Step 1: 写文件**
|
||||
|
||||
完整内容:
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
|
||||
// 内网联调:发现失败时的开发机 IP 输入框(纯 IMGUI,风格同热更层 DevServerSwitchUI)。
|
||||
// 只负责输入与三态状态机,不做网络/文件 IO——探测与写配置由 LoadDll.CoLocalDiscoverThenDownload 编排:
|
||||
// [确定] → Confirmed,外部拿 InputIp 去探测;失败则外部调 ResetToWaiting(错误文案) 拨回弹框;
|
||||
// [跳过] → Skipped,外部走 127.0.0.1 回退。
|
||||
public sealed class DevServerPrompt : MonoBehaviour
|
||||
{
|
||||
public enum PromptState { Waiting, Confirmed, Skipped }
|
||||
|
||||
public PromptState State { get; private set; } = PromptState.Waiting;
|
||||
public string InputIp = "";
|
||||
public string Error = ""; // 非空则红字显示
|
||||
|
||||
// 外部探测失败后调用:显示错误并回到可输入状态。
|
||||
public void ResetToWaiting(string error)
|
||||
{
|
||||
Error = error ?? "";
|
||||
State = PromptState.Waiting;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (State != PromptState.Waiting) return;
|
||||
const int W = 560, H = 190;
|
||||
GUILayout.BeginArea(new Rect((Screen.width - W) / 2, (Screen.height - H) / 3, W, H), GUI.skin.box);
|
||||
GUILayout.Label("内网发现失败:请输入开发机 IP(网关须 --lan --devToken xw-dev)");
|
||||
InputIp = GUILayout.TextField(InputIp ?? "", GUILayout.Height(44));
|
||||
if (!string.IsNullOrEmpty(Error))
|
||||
{
|
||||
GUI.color = new Color(1f, 0.4f, 0.3f);
|
||||
GUILayout.Label(Error);
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("确定", GUILayout.Height(44))) { Error = ""; State = PromptState.Confirmed; }
|
||||
if (GUILayout.Button("跳过", GUILayout.Height(44), GUILayout.Width(120))) State = PromptState.Skipped;
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 目视核对**
|
||||
|
||||
仅依赖 UnityEngine;无网络/文件 IO;`State` 外部只读(private set),状态回拨只经 `ResetToWaiting`。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: LoadDll 流程重写
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Base\LoadDll.cs`(`CoLocalDiscoverThenDownload` 方法,约 191-220 行,`#if !UNITY_WEBGL` 块内)
|
||||
|
||||
- [ ] **Step 1: 整体替换 CoLocalDiscoverThenDownload**
|
||||
|
||||
把方法(含其上两行注释)整体替换为:
|
||||
|
||||
```csharp
|
||||
//内网热更:先 UDP 广播发现开发机 CDN(拿其 LAN IP),成功则改用它;失败弹输入框让开发者输入开发机 IP,
|
||||
//探测成功写入 persistentDataPath/dev_server.txt(下次启动第 1 轮直接命中不再弹);[跳过]回退 127.0.0.1(真机需 adb reverse tcp:15081)。
|
||||
//token 须与热更层 GlobalData.LanDiscoveryToken 及服务端 Gateway.Runner --devToken 一致(均为 "xw-dev")。
|
||||
IEnumerator CoLocalDiscoverThenDownload()
|
||||
{
|
||||
Debug.Log($"[LoadDll] CoLocalDiscoverThenDownload begin");
|
||||
//第 1 轮:广播/loopback/定向广播 + dev_server.txt 已存 IP(若有)。
|
||||
//Discover 会阻塞(UDP 收发+超时),放后台线程跑,协程逐帧等它结束,避免卡加载界面;
|
||||
//persistentDataPath 不能在后台线程访问,配置在主线程先读好传入。
|
||||
string cdnRoot = null;
|
||||
string savedIp = DevServerConfig.Load();
|
||||
{
|
||||
Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, savedIp); }) { IsBackground = true };
|
||||
th.Start();
|
||||
while (th.IsAlive)
|
||||
yield return null;
|
||||
}
|
||||
|
||||
//第 1 轮失败 → 弹输入框(预填旧值,开发机换 IP 时可直接改);输入 IP 验证探测成功才写配置。
|
||||
if (string.IsNullOrEmpty(cdnRoot))
|
||||
{
|
||||
DevServerPrompt prompt = gameObject.AddComponent<DevServerPrompt>();
|
||||
prompt.InputIp = savedIp ?? "";
|
||||
while (string.IsNullOrEmpty(cdnRoot))
|
||||
{
|
||||
while (prompt.State == DevServerPrompt.PromptState.Waiting)
|
||||
yield return null;
|
||||
if (prompt.State == DevServerPrompt.PromptState.Skipped)
|
||||
break;
|
||||
|
||||
//Confirmed:先主线程校验格式,非法不发起探测直接回弹框。
|
||||
string inputIp = (prompt.InputIp ?? "").Trim();
|
||||
if (!System.Net.IPAddress.TryParse(inputIp, out _))
|
||||
{
|
||||
prompt.ResetToWaiting($"IP 格式不对:{inputIp}");
|
||||
continue;
|
||||
}
|
||||
Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, inputIp); }) { IsBackground = true };
|
||||
th.Start();
|
||||
while (th.IsAlive)
|
||||
yield return null;
|
||||
if (string.IsNullOrEmpty(cdnRoot))
|
||||
{
|
||||
prompt.ResetToWaiting($"探测失败:连不到 {inputIp}:48923(网关起了吗?防火墙放行 UDP 48923 了吗?)");
|
||||
continue;
|
||||
}
|
||||
DevServerConfig.Save(inputIp);
|
||||
Debug.Log($"[LoadDll] dev server ip saved: {inputIp}");
|
||||
}
|
||||
Destroy(prompt);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(cdnRoot))
|
||||
{
|
||||
if (!cdnRoot.EndsWith("/"))
|
||||
cdnRoot += "/";
|
||||
//发现回复的 ResourceBaseUrl 是 CDN 根(http://<LAN-IP>:15081/),DLL 热更在其下的 XWorld/<platform>/。
|
||||
UpdateServer = $"{cdnRoot}XWorld/{sPlatform}/";
|
||||
Debug.Log($"[LoadDll] LAN CDN discovered: {UpdateServer}");
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateServer = $"http://127.0.0.1:15081/XWorld/{sPlatform}/";
|
||||
Debug.LogWarning($"[LoadDll] LAN CDN discovery failed/skipped, fallback {UpdateServer} (真机需 adb reverse tcp:15081 tcp:15081,且开发机网关须 --lan --devToken xw-dev)");
|
||||
}
|
||||
|
||||
yield return DownLoadAssets(this.StartGame);
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- `Thread` 已有 using(原方法就用);`System.Net.IPAddress` 全限定,不新增 using。
|
||||
- 探测成功但来源无法区分(广播/文件 IP),故第 1 轮成功**不**写文件——只有弹框输入并验证成功才 `Save`(spec 行为规格)。
|
||||
- `Destroy(prompt)` 在跳出循环后统一执行(成功或跳过都销毁)。
|
||||
|
||||
- [ ] **Step 2: 目视核对**
|
||||
|
||||
方法内所有 `yield return` 都在协程主体(不在 lambda 内);`cdnRoot` 由后台线程赋值、主线程在 `th.IsAlive == false` 后才读(与原代码同模式);两处 `Thread` 变量分别在独立作用域(第 1 轮包在 `{}` 块里,无重名冲突)。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 端到端验证
|
||||
|
||||
**Files:** 无代码改动,纯验证。
|
||||
|
||||
- [ ] **Step 1: 服务端全量测试**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
||||
```
|
||||
|
||||
预期:172 条全部通过。
|
||||
|
||||
- [ ] **Step 2: 残留扫描**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && grep -rn "Multicast\|239.192.48.92\|192.168.130.40\|DevExtraProbeIps\|GetLocalIPv4Addresses" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests
|
||||
```
|
||||
|
||||
预期:无输出。
|
||||
|
||||
- [ ] **Step 3: 编辑器同机自测(需用户配合)**
|
||||
|
||||
起网关(`--lan --devToken xw-dev`)+ 编辑器运行:loopback 一轮即中,弹框不出现,`[LoadDll] LAN CDN discovered` 正常——与改前行为一致。
|
||||
|
||||
- [ ] **Step 4: 真机验收(需用户配合,重打 APK——Base 是 AOT 底包,DLL 热更交付不了)**
|
||||
|
||||
按 spec 验收标准:
|
||||
1. 跨网段首启(先清 App 数据或删 dev_server.txt):第 1 轮失败 → 弹框 → 输入 192.168.130.40 → 探测成功 → 正常加载
|
||||
2. 重启:不弹框,第 1 轮直接命中(日志 targets 里含 192.168.130.40)
|
||||
3. [跳过]:回退 127.0.0.1,与现状一致
|
||||
4. (可选)改 dev_server.txt 为错误 IP 模拟开发机换 IP:弹框预填旧值可改
|
||||
@@ -0,0 +1,372 @@
|
||||
# 组播服务发现叠加 实施计划
|
||||
|
||||
> **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:** 在现有 UDP 广播发现机制上叠加组播探测路径(组 `239.192.48.92:48923`,TTL=8),使发现请求有机会跨网段到达开发机;现有广播/loopback/写死 IP 路径全部保留。
|
||||
|
||||
**Architecture:** 服务端响应器在现有 `0.0.0.0:48923` socket 上逐网卡加入组播组(收发逻辑零改动);客户端两份探测器(热更层 + 底包 AOT 层,协议内联须同步)在每轮重发中逐网卡设 `MulticastInterface` 向组播组发查询,回复仍走单播。客户端只发不收组播,Android 无需 MulticastLock。
|
||||
|
||||
**Tech Stack:** C# / .NET(netstandard2.1 共享协议)、Unity 客户端、xUnit 服务端测试。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-06-multicast-discovery-design.md`
|
||||
|
||||
**注意:** 项目根 `.git` 是空目录、非有效 git 仓库,故本计划省略 commit 步骤,以"运行测试/编译验证"作为每任务收尾。若后续 `git init`,可按任务边界补提交。
|
||||
|
||||
**关键背景(执行者必读):**
|
||||
- `Server\Framework.Shared\Framework.Shared.csproj` 通过 `<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />` 直接编译 Client 共享源码 → 协议常量只需改 `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` 一处,服务端自动生效。
|
||||
- `Client\Assets\Base\LocalCdnDiscovery.cs` 属底包(AOT)程序集,**不能**引用上述共享程序集(运行时才由 LoadDll 加载),协议常量是手工内联的,改动须与共享层同步(文件头注释已有此约定)。
|
||||
- 发现机制是"多路齐发、谁通算谁",所有网络操作失败均静默吞掉,不中断其余路径——新增代码须保持此风格。
|
||||
- 测试命令均在仓库根 `D:\UD\AI\AIC#Project` 下执行;路径含 `#`,bash 中必须加引号。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 协议常量 + 服务端组播 join(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`(加常量)
|
||||
- Modify: `Server\Gateway\DevDiscoveryResponder.cs`(逐网卡 join 组播组)
|
||||
- Test: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`(加组播用例)
|
||||
|
||||
- [ ] **Step 1: 加协议常量**
|
||||
|
||||
在 `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` 的 `DevDiscovery` 静态类中新增(放在 `Port` 之后):
|
||||
|
||||
```csharp
|
||||
// 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由;
|
||||
// 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。
|
||||
public const string MulticastGroup = "239.192.48.92";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写失败测试**
|
||||
|
||||
在 `Server\Gateway.Tests\DevDiscoveryResponderTests.cs` 中新增用例(与现有用例同级):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task MulticastQuery_ReceivesReply()
|
||||
{
|
||||
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
|
||||
responder.Start();
|
||||
try
|
||||
{
|
||||
using var client = new UdpClient(AddressFamily.InterNetwork);
|
||||
// 同机回环验证组播收包路径:loopback 开、TTL=1 不出网段
|
||||
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);
|
||||
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1);
|
||||
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
|
||||
{
|
||||
ProtoVersion = DevDiscovery.ProtoVersion,
|
||||
Nonce = 21,
|
||||
Token = "secret",
|
||||
});
|
||||
var groupEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), responder.Port);
|
||||
await client.SendAsync(q, q.Length, groupEp);
|
||||
|
||||
var recv = client.ReceiveAsync();
|
||||
var done = await Task.WhenAny(recv, Task.Delay(2000));
|
||||
Assert.Same(recv, done); // 超时即失败:响应器没收到组播查询
|
||||
UdpReceiveResult result = await recv;
|
||||
Assert.True(DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply));
|
||||
Assert.Equal(21u, reply.Nonce);
|
||||
Assert.Equal("secret", reply.Token);
|
||||
}
|
||||
finally { await responder.StopAsync(); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑测试确认失败**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
|
||||
```
|
||||
|
||||
预期:FAIL,`Assert.Same` 断言失败(2 秒超时,响应器未 join 组播组收不到查询)。
|
||||
|
||||
- [ ] **Step 4: 实现服务端逐网卡 join**
|
||||
|
||||
修改 `Server\Gateway\DevDiscoveryResponder.cs`:
|
||||
|
||||
文件头 using 增加:
|
||||
|
||||
```csharp
|
||||
using System.Net.NetworkInformation;
|
||||
```
|
||||
|
||||
构造函数中 `Port = ...` 赋值行之后新增一行调用:
|
||||
|
||||
```csharp
|
||||
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
|
||||
```
|
||||
|
||||
类中新增私有静态方法(放在构造函数之后):
|
||||
|
||||
```csharp
|
||||
// 逐块 Up 状态非回环 IPv4 网卡加入组播组:默认 JoinMulticastGroup(group) 只加默认网卡,
|
||||
// 多网卡开发机(有线+无线/虚拟网卡)会漏收。逐网卡 try/catch,单卡失败不中断其余;
|
||||
// 一块都没 join 上时兜底按默认网卡 join 一次。
|
||||
private static void JoinMulticastOnAllNics(UdpClient udp, IPAddress group)
|
||||
{
|
||||
bool joinedAny = false;
|
||||
NetworkInterface[] nics;
|
||||
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
||||
catch { nics = Array.Empty<NetworkInterface>(); }
|
||||
foreach (NetworkInterface ni in nics)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
IPInterfaceProperties props;
|
||||
try { props = ni.GetIPProperties(); }
|
||||
catch { continue; }
|
||||
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
try { udp.JoinMulticastGroup(group, ua.Address); joinedAny = true; }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
}
|
||||
if (!joinedAny)
|
||||
{
|
||||
try { udp.JoinMulticastGroup(group); }
|
||||
catch (SocketException) { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
|
||||
```
|
||||
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **Step 6: 跑服务端全量测试防回归**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
||||
```
|
||||
|
||||
预期:全部 PASS(尤其 `DevDiscoveryResponderTests` 原有 3 条、`GameServerHostDiscoveryTests`、`DevDiscoveryCodecTests`)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 客户端热更层 DevServerDiscovery 发组播
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
|
||||
|
||||
无法为 Unity 客户端写自动化测试,验证靠 Task 4 的编辑器实测。
|
||||
|
||||
- [ ] **Step 1: 更新写死 IP 的注释**
|
||||
|
||||
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
|
||||
|
||||
```csharp
|
||||
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
|
||||
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
|
||||
// 已叠加组播探测(DevDiscovery.MulticastGroup):若实测组播能跨网段,可删本兜底。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: socket 初始化后设组播 TTL**
|
||||
|
||||
在 `Run()` 中 `udp.Client.ReceiveTimeout = 250;` 之后新增:
|
||||
|
||||
```csharp
|
||||
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 每轮重发时逐网卡发组播**
|
||||
|
||||
在 `Run()` 中 `IPEndPoint[] targets = targetList.ToArray();` 之后新增:
|
||||
|
||||
```csharp
|
||||
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
|
||||
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
|
||||
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), DevDiscovery.Port);
|
||||
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
|
||||
Debug.Log("[DevTest] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
|
||||
```
|
||||
|
||||
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
|
||||
|
||||
```csharp
|
||||
foreach (IPAddress nicAddr in multicastNics)
|
||||
{
|
||||
try
|
||||
{
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
|
||||
udp.Send(query, query.Length, multicastEp);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (multicastNics.Count == 0)
|
||||
{
|
||||
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增网卡地址枚举辅助方法**
|
||||
|
||||
在 `GetDirectedBroadcasts()` 之后新增(枚举骨架与其一致,但无需掩码):
|
||||
|
||||
```csharp
|
||||
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
|
||||
private static List<IPAddress> GetLocalIPv4Addresses()
|
||||
{
|
||||
List<IPAddress> list = new List<IPAddress>();
|
||||
NetworkInterface[] nics;
|
||||
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
||||
catch (Exception e) { Debug.LogWarning("[DevTest] enum nics for multicast failed: " + e.Message); return list; }
|
||||
|
||||
foreach (NetworkInterface ni in nics)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
|
||||
IPInterfaceProperties props;
|
||||
try { props = ni.GetIPProperties(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
list.Add(ua.Address);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 确认编译无错**
|
||||
|
||||
Unity 编辑器会自动编译;若编辑器未开,至少目视核对:新代码只用了文件已 using 的命名空间(`System.Net`、`System.Net.NetworkInformation`、`System.Net.Sockets`),`DevDiscovery.MulticastGroup` 来自已 using 的 `XWorld.Framework.Protocol`。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 客户端底包 LocalCdnDiscovery 发组播(与热更层同步)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client\Assets\Base\LocalCdnDiscovery.cs`
|
||||
|
||||
底包不能引用共享程序集,常量手工内联;逻辑与 Task 2 完全对应。
|
||||
|
||||
- [ ] **Step 1: 内联组播常量**
|
||||
|
||||
在 `private const int Port = 48923;` 一组常量之后(`TypeReply` 之后)新增:
|
||||
|
||||
```csharp
|
||||
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新写死 IP 的注释**
|
||||
|
||||
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
|
||||
|
||||
```csharp
|
||||
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
|
||||
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
|
||||
// 已叠加组播探测(MulticastGroup):若实测组播能跨网段,可删本兜底。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: socket 初始化后设组播 TTL**
|
||||
|
||||
在 `Discover()` 中 `udp.Client.ReceiveTimeout = 250;` 之后新增:
|
||||
|
||||
```csharp
|
||||
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 每轮重发时逐网卡发组播**
|
||||
|
||||
在 `Discover()` 中 `IPEndPoint[] targets = targetList.ToArray();` 之后新增:
|
||||
|
||||
```csharp
|
||||
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
|
||||
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
|
||||
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(MulticastGroup), Port);
|
||||
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
|
||||
UnityEngine.Debug.Log("[Discovery] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
|
||||
```
|
||||
|
||||
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
|
||||
|
||||
```csharp
|
||||
foreach (IPAddress nicAddr in multicastNics)
|
||||
{
|
||||
try
|
||||
{
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
|
||||
udp.Send(query, query.Length, multicastEp);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (multicastNics.Count == 0)
|
||||
{
|
||||
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 新增网卡地址枚举辅助方法**
|
||||
|
||||
在 `GetDirectedBroadcasts()` 之后新增:
|
||||
|
||||
```csharp
|
||||
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
|
||||
private static List<IPAddress> GetLocalIPv4Addresses()
|
||||
{
|
||||
List<IPAddress> list = new List<IPAddress>();
|
||||
NetworkInterface[] nics;
|
||||
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
||||
catch (Exception e) { UnityEngine.Debug.LogWarning("[Discovery] enum nics for multicast failed: " + e.Message); return list; }
|
||||
|
||||
foreach (NetworkInterface ni in nics)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||
|
||||
IPInterfaceProperties props;
|
||||
try { props = ni.GetIPProperties(); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
list.Add(ua.Address);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 目视核对两边一致性**
|
||||
|
||||
对照 `DevServerDiscovery.cs`:组播组地址、TTL 值、逐网卡发送逻辑、兜底发送逻辑须完全一致(仅日志前缀 `[Discovery]`/`[DevTest]` 和常量引用方式不同)。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 端到端验证
|
||||
|
||||
**Files:** 无代码改动,纯验证。
|
||||
|
||||
- [ ] **Step 1: 服务端全量测试**
|
||||
|
||||
```bash
|
||||
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
||||
```
|
||||
|
||||
预期:全部 PASS。
|
||||
|
||||
- [ ] **Step 2: 编辑器同机自测(需用户配合)**
|
||||
|
||||
启动服务端网关(带发现响应器)与 Unity 编辑器客户端,观察客户端日志:
|
||||
- 出现 `[DevTest] multicast=239.192.48.92:48923 via N nic(s)`(N ≥ 1)
|
||||
- 出现 `[Discovery] multicast=239.192.48.92:48923 via N nic(s)`(底包路径,若走到)
|
||||
- 发现成功(同机场景下 loopback 路径本来就通,此步主要确认组播代码不报错、不拖慢发现)
|
||||
|
||||
- [ ] **Step 3: 真机验证(需用户配合,自动化范围之外)**
|
||||
|
||||
按 spec 验收标准:
|
||||
1. 真机同网段:发现成功。
|
||||
2. 真机跨网段:观察组播路径是否通。通 → 建后续任务删 `DevExtraProbeIps` 写死 IP;不通 → 保留写死 IP,评估"探测 IP 可配置化"方案(spec 非目标节)。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
# 服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划
|
||||
|
||||
> **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:** 同版本覆盖重发不再被文件锁挡住且发布即生效——DLL 字节加载(不锁文件)+ Registry 按磁盘内容指纹自动失效重载。
|
||||
|
||||
**Architecture:** `GameModuleAlc` 两处 `LoadFromAssemblyPath` 改 `File.ReadAllBytes`+`LoadFromStream`(无文件句柄);`GameModuleRegistry` 加内容指纹:`Acquire` 命中缓存时重算磁盘指纹,变化则旧条目退役(`_retired`,旧房间打完引用归零即卸载)、加载新内容。生产环境版本不可变、指纹恒命中,行为零变化。
|
||||
|
||||
**Tech Stack:** .NET 10(Server.Host)、xUnit、复用 `XWorld.PublishTool.Md5Util`(Server.Host 已引用 PublishTool)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-07-minigame-publish-pipeline-design.md` §4.3.2
|
||||
|
||||
**约定:无 git(不提交,commit 步骤替换为验证 checkpoint)。仓库根 `<repo>` = `D:\UD\AI\AIC#Project`,所有命令在 `<repo>` 下执行。基线:`dotnet test Server/Server.sln` = 182 通过 / 0 失败(若 `Server/Server.Host.Tests/TestGames/` 不存在,先跑 `bash Server/build-test-games.sh`)。测试注意:`TestGames.Root` 是共享只读夹具——**涉及改写文件的测试必须先拷到临时 gamesRoot,不许原地改**。**
|
||||
|
||||
---
|
||||
|
||||
### Task 1: GameModuleAlc 字节加载(去文件锁),TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `Server/Server.Host.Tests/ModuleFileLockTests.cs`
|
||||
- Modify: `Server/Server.Host/GameModuleAlc.cs`
|
||||
|
||||
- [ ] **Step 1.1: 写失败测试** `ModuleFileLockTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Host.Tests
|
||||
{
|
||||
// 字节加载去锁:模块加载期间磁盘 DLL 可被覆盖/删除(同版本覆盖重发的前提)
|
||||
public class ModuleFileLockTests
|
||||
{
|
||||
// 把共享只读夹具 rps/1 拷到独立临时 gamesRoot(不许改 TestGames.Root 原件)
|
||||
private static string CopyRps1ToTempRoot()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
|
||||
string dst = Path.Combine(root, "rps", "1");
|
||||
Directory.CreateDirectory(dst);
|
||||
string src = Path.Combine(TestGames.Root, "rps", "1");
|
||||
foreach (var f in Directory.GetFiles(src))
|
||||
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedModule_DoesNotLockDlls_OverwriteAndDeleteSucceed()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var module = new GameModuleLoader().Load(root, "rps", 1);
|
||||
try
|
||||
{
|
||||
string serverDll = Path.Combine(root, "rps", "1", "RPS.Server.dll");
|
||||
string coreDll = Path.Combine(root, "rps", "1", "RPS.Core.dll");
|
||||
|
||||
// 加载期间覆盖写入 —— LoadFromAssemblyPath 会锁文件抛 IOException,字节加载应成功
|
||||
byte[] original = File.ReadAllBytes(serverDll);
|
||||
File.WriteAllBytes(serverDll, original);
|
||||
|
||||
// 加载期间删除
|
||||
File.Delete(coreDll);
|
||||
Assert.False(File.Exists(coreDll));
|
||||
|
||||
// 模块本体仍可用(IL 已在内存)
|
||||
var room = module.CreateRoom();
|
||||
Assert.NotNull(room);
|
||||
}
|
||||
finally
|
||||
{
|
||||
module.Unload();
|
||||
try { Directory.Delete(root, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: 跑测试确认失败**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project" && dotnet test Server/Server.Host.Tests -nologo --filter ModuleFileLockTests 2>&1 | tail -6`
|
||||
Expected: FAIL,`IOException`(文件正由另一进程使用)——Windows 上 `LoadFromAssemblyPath` 锁定 DLL。
|
||||
|
||||
- [ ] **Step 1.3: 实现字节加载** —— `GameModuleAlc.cs` 整文件替换为:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace XWorld.Server.Host
|
||||
{
|
||||
// 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文。
|
||||
// DLL 一律字节加载(LoadFromStream):不持有文件句柄,同版本覆盖重发不被文件锁挡住。
|
||||
// 代价:字节加载的 Assembly.Location 为空——小游戏 DLL 不得依赖它。
|
||||
internal sealed class GameModuleAlc : AssemblyLoadContext
|
||||
{
|
||||
private readonly string _moduleDir;
|
||||
|
||||
public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
|
||||
{
|
||||
_moduleDir = moduleDir;
|
||||
}
|
||||
|
||||
protected override Assembly Load(AssemblyName assemblyName)
|
||||
{
|
||||
// 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
|
||||
// 约束:XWorld.Framework.Shared 只能依赖 BCL(System.*);若将来加第三方依赖,需同步扩展下面白名单。
|
||||
if (assemblyName.Name == "XWorld.Framework.Shared")
|
||||
return null;
|
||||
|
||||
// BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
|
||||
string n = assemblyName.Name;
|
||||
if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
|
||||
n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
|
||||
n == "System" || n == "netstandard" || n == "mscorlib"))
|
||||
return null;
|
||||
|
||||
// 小游戏私有程序集放在版本目录里,载入本可卸载上下文
|
||||
string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
|
||||
if (File.Exists(candidate))
|
||||
return LoadBytes(candidate);
|
||||
|
||||
return null; // 其余交默认上下文
|
||||
}
|
||||
|
||||
public Assembly LoadEntry(string serverDllPath) => LoadBytes(serverDllPath);
|
||||
|
||||
private Assembly LoadBytes(string path)
|
||||
{
|
||||
using (var ms = new MemoryStream(File.ReadAllBytes(path)))
|
||||
return LoadFromStream(ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: 跑测试确认通过 + Server.Host.Tests 全量**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
|
||||
Expected: 全 PASS(51 原有 + 1 新 = 52;`AlcUnloadTests` 的弱引用 GC 断言对 LoadFromStream 同样成立)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GameModuleRegistry 内容指纹自动失效,TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `Server/Server.Host.Tests/RegistryContentInvalidationTests.cs`
|
||||
- Modify: `Server/Server.Host/GameModuleRegistry.cs`(整文件替换)
|
||||
|
||||
- [ ] **Step 2.1: 写失败测试** `RegistryContentInvalidationTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit;
|
||||
using XWorld.Server.Host;
|
||||
|
||||
namespace XWorld.Server.Host.Tests
|
||||
{
|
||||
// 同 (gameId,version) 磁盘内容变化(覆盖重发)→ Registry 自动失效重载;
|
||||
// 旧房间打完旧代码(退役条目引用归零即卸载),新房间用新代码。
|
||||
public class RegistryContentInvalidationTests
|
||||
{
|
||||
private static string CopyRps1ToTempRoot()
|
||||
{
|
||||
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
|
||||
string dst = Path.Combine(root, "rps", "1");
|
||||
Directory.CreateDirectory(dst);
|
||||
string src = Path.Combine(TestGames.Root, "rps", "1");
|
||||
foreach (var f in Directory.GetFiles(src))
|
||||
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
|
||||
return root;
|
||||
}
|
||||
|
||||
// 改变内容指纹(game.json 在指纹范围内;追加空白不影响 JSON 解析)
|
||||
private static void TouchContent(string root)
|
||||
{
|
||||
string gameJson = Path.Combine(root, "rps", "1", "game.json");
|
||||
File.AppendAllText(gameJson, " ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameContent_ReturnsCachedInstance()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
var b = reg.Acquire("rps", 1);
|
||||
Assert.Same(a, b);
|
||||
Assert.Equal(2, reg.RefCount("rps", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_AfterRefZero_ReloadsNewInstance()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
reg.Release(a); // 引用归零,未淘汰 → 保留缓存
|
||||
TouchContent(root);
|
||||
var b = reg.Acquire("rps", 1);
|
||||
Assert.NotSame(a, b); // 内容变了 → 新模块实例
|
||||
Assert.Equal(1, reg.RefCount("rps", 1));
|
||||
Assert.True(reg.IsLoaded("rps", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_WhileOldActive_OldAndNewCoexist_ReleaseBothCorrect()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var oldM = reg.Acquire("rps", 1); // 旧房间在打
|
||||
TouchContent(root);
|
||||
var newM = reg.Acquire("rps", 1); // 新房间 → 新内容
|
||||
Assert.NotSame(oldM, newM);
|
||||
Assert.Equal(1, reg.RefCount("rps", 1)); // RefCount 只统计当前条目
|
||||
|
||||
reg.Release(oldM); // 旧房间结束:退役条目按实例识别释放,不抛
|
||||
reg.Release(newM);
|
||||
Assert.Equal(0, reg.RefCount("rps", 1));
|
||||
Assert.True(reg.IsLoaded("rps", 1)); // 当前条目未被淘汰 → 保留复用
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentChange_RetiredModule_UnloadsAndReclaims()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var weak = AcquireChangeReleaseOld(root);
|
||||
for (int i = 0; i < 12 && weak.IsAlive; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
Assert.False(weak.IsAlive, "退役模块的 ALC 未被回收 —— 存在泄漏引用");
|
||||
}
|
||||
|
||||
// 独立方法确保局部强引用随返回消失(同 AlcUnloadTests 套路)
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference AcquireChangeReleaseOld(string root)
|
||||
{
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var oldM = reg.Acquire("rps", 1);
|
||||
TouchContent(root);
|
||||
var newM = reg.Acquire("rps", 1); // oldM 退役
|
||||
var weak = new WeakReference(oldM);
|
||||
reg.Release(oldM); // 退役 + 归零 → 卸载
|
||||
reg.Release(newM);
|
||||
return weak;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDir_ServesCachedEntry()
|
||||
{
|
||||
string root = CopyRps1ToTempRoot();
|
||||
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
|
||||
var a = reg.Acquire("rps", 1);
|
||||
string dir = Path.Combine(root, "rps", "1");
|
||||
string away = dir + ".away";
|
||||
Directory.Move(dir, away); // 模拟覆盖交换的瞬间窗口
|
||||
try
|
||||
{
|
||||
var b = reg.Acquire("rps", 1); // 指纹算不出 → 用缓存兜底
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
finally { Directory.Move(away, dir); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: 跑测试确认失败**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo --filter RegistryContentInvalidationTests 2>&1 | tail -6`
|
||||
Expected: `SameContent/MissingDir` 可能通过(旧实现即缓存),`ContentChange_*` 三个 FAIL(`Assert.NotSame` 失败——旧实现不看内容)。
|
||||
|
||||
- [ ] **Step 2.3: 实现** —— `GameModuleRegistry.cs` 整文件替换为:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace XWorld.Server.Host
|
||||
{
|
||||
// (gameId,version) -> 已加载模块;按引用计数获取/释放。
|
||||
// 淘汰有两个来源:①同 gameId 更高版本加载成功;②同 (gameId,version) 磁盘内容指纹变化(覆盖重发)。
|
||||
// 被淘汰且引用归零即卸载;内容变化时新旧模块并存至旧房间结束(_retired,Release 按实例识别)。
|
||||
// 生产环境版本不可变、指纹恒命中缓存,行为与旧实现一致。
|
||||
public sealed class GameModuleRegistry
|
||||
{
|
||||
private sealed class Entry
|
||||
{
|
||||
public LoadedGameModule Module;
|
||||
public int RefCount;
|
||||
public bool Superseded;
|
||||
public string Fingerprint;
|
||||
}
|
||||
|
||||
private readonly GameModuleLoader _loader;
|
||||
private readonly string _gamesRoot;
|
||||
private readonly Dictionary<string, Entry> _current = new Dictionary<string, Entry>();
|
||||
private readonly List<Entry> _retired = new List<Entry>();
|
||||
|
||||
public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
|
||||
{
|
||||
_loader = loader; _gamesRoot = gamesRoot;
|
||||
}
|
||||
|
||||
private static string Key(string gameId, int version) => $"{gameId}@{version}";
|
||||
private string ModuleDir(string gameId, int version) => Path.Combine(_gamesRoot, gameId, version.ToString());
|
||||
|
||||
public LoadedGameModule Acquire(string gameId, int version)
|
||||
{
|
||||
string key = Key(gameId, version);
|
||||
if (_current.TryGetValue(key, out var e))
|
||||
{
|
||||
// 覆盖重发检测:磁盘内容指纹变化 → 旧条目退役、加载新内容。
|
||||
// 指纹算不出(目录处于覆盖交换的瞬间窗口/被删)→ 视为未变化,用缓存兜底。
|
||||
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
|
||||
if (fp == null || fp == e.Fingerprint)
|
||||
{
|
||||
e.RefCount++;
|
||||
return e.Module;
|
||||
}
|
||||
|
||||
var reloaded = LoadEntry(gameId, version); // 失败直接抛出:旧条目原样保留,不污染状态
|
||||
Retire(e);
|
||||
_current[key] = reloaded;
|
||||
SupersedeOlderVersions(gameId, version);
|
||||
reloaded.RefCount++;
|
||||
return reloaded.Module;
|
||||
}
|
||||
|
||||
var loaded = LoadEntry(gameId, version);
|
||||
_current[key] = loaded;
|
||||
SupersedeOlderVersions(gameId, version);
|
||||
loaded.RefCount++;
|
||||
return loaded.Module;
|
||||
}
|
||||
|
||||
public void Release(LoadedGameModule module)
|
||||
{
|
||||
string key = Key(module.GameId, module.Version);
|
||||
if (_current.TryGetValue(key, out var e) && ReferenceEquals(e.Module, module))
|
||||
{
|
||||
if (e.RefCount <= 0)
|
||||
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
|
||||
e.RefCount--;
|
||||
if (e.RefCount <= 0 && e.Superseded)
|
||||
{
|
||||
_current.Remove(key);
|
||||
module.Unload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 退役条目:同版本被覆盖重发淘汰后仍被旧房间持有,按实例识别
|
||||
for (int i = 0; i < _retired.Count; i++)
|
||||
{
|
||||
if (!ReferenceEquals(_retired[i].Module, module)) continue;
|
||||
var r = _retired[i];
|
||||
if (r.RefCount <= 0)
|
||||
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
|
||||
r.RefCount--;
|
||||
if (r.RefCount <= 0)
|
||||
{
|
||||
_retired.RemoveAt(i);
|
||||
module.Unload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 未知模块的释放忽略(与旧实现对不存在 key 直接 return 的行为一致)
|
||||
}
|
||||
|
||||
public int RefCount(string gameId, int version)
|
||||
=> _current.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;
|
||||
|
||||
public bool IsLoaded(string gameId, int version) => _current.ContainsKey(Key(gameId, version));
|
||||
|
||||
private Entry LoadEntry(string gameId, int version)
|
||||
{
|
||||
// 指纹在加载前采样:加载成功即与所载内容对应(单线程 tick actor 下无并发写窗口)
|
||||
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
|
||||
var loaded = _loader.Load(_gamesRoot, gameId, version);
|
||||
return new Entry { Module = loaded, RefCount = 0, Superseded = false, Fingerprint = fp };
|
||||
}
|
||||
|
||||
private void SupersedeOlderVersions(string gameId, int version)
|
||||
{
|
||||
foreach (var kv in _current)
|
||||
if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
|
||||
kv.Value.Superseded = true;
|
||||
}
|
||||
|
||||
private void Retire(Entry e)
|
||||
{
|
||||
e.Superseded = true;
|
||||
if (e.RefCount <= 0) e.Module.Unload();
|
||||
else _retired.Add(e);
|
||||
}
|
||||
|
||||
// 目录内容指纹:全部 *.dll + game.json 按名 Ordinal 排序拼 name=md5; 再整体 md5;
|
||||
// 读不到(目录不存在/IO 竞态)返回 null 由调用方兜底
|
||||
private static string ComputeFingerprintOrNull(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir)) return null;
|
||||
var names = Directory.GetFiles(dir, "*.dll")
|
||||
.Select(Path.GetFileName)
|
||||
.Append("game.json")
|
||||
.Where(n => File.Exists(Path.Combine(dir, n)))
|
||||
.Distinct()
|
||||
.OrderBy(n => n, StringComparer.Ordinal);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var n in names)
|
||||
sb.Append(n).Append('=')
|
||||
.Append(XWorld.PublishTool.Md5Util.OfFile(Path.Combine(dir, n))).Append(';');
|
||||
return XWorld.PublishTool.Md5Util.OfBytes(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
catch (IOException) { return null; }
|
||||
catch (UnauthorizedAccessException) { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: 跑新测试 + Server.Host.Tests 全量**
|
||||
|
||||
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
|
||||
Expected: 全 PASS(52 + 5 新 = 57)。既有 `GameModuleRegistryTests` 5 个必须原样通过(`Acquire_SameVersionTwice`/`Release_ToZero`/`NewVersion_Supersedes`/`FailedNewVersionLoad`/`Release_MoreThanAcquired`——公共 API 与语义未变)。
|
||||
|
||||
- [ ] **Step 2.5: Checkpoint——全解决方案回归**
|
||||
|
||||
Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8`
|
||||
Expected: 0 失败(182 基线 + 6 新 = 188 左右;Gateway 的 RoomHost/ServerLoop 只用 Acquire/Release,零改动)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 文档同步 + 终验
|
||||
|
||||
**Files:**
|
||||
- Modify: `Tools/PcMiniGameSmoke/README.md`(「运行中热更」小节)
|
||||
|
||||
- [ ] **Step 3.1: 改 README 注意事项**——把
|
||||
|
||||
```markdown
|
||||
注意:网关运行中且某版本已被房间加载时,**覆盖重发同版本**会因 DLL 文件锁失败——
|
||||
运行中更新一律发新版本号(新版本自动淘汰旧版本,旧房间打完即卸载)。
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```markdown
|
||||
**同版本覆盖重发也支持**:服务器 DLL 为字节加载(不锁文件),覆盖落位总能成功;
|
||||
网关按磁盘内容指纹自动失效——下一个新房间即用新代码,已入房玩家打完旧代码后旧模块自动卸载。
|
||||
(生产环境仍建议版本号递增,便于客户端 AB 与服务器 DLL 的版本对应关系可追溯。)
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: 终验**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd "D:/UD/AI/AIC#Project"
|
||||
dotnet test Server/Server.sln -nologo 2>&1 | tail -8
|
||||
```
|
||||
Expected: 0 失败。
|
||||
|
||||
- [ ] **Step 3.3: 人工核对项(交给用户)**:网关运行中(Editor Play 或命令行起 `Gateway.Runner --gamesRoot deploy/minigame`),菜单对同版本"覆盖重发"RPS——落位成功(无文件锁报错);不重启网关再匹配进一局,日志可见模块重新加载(新 ALC)。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:§4.3.2 字节加载(Task 1)、指纹失效/退役并存/Release 按实例(Task 2)、README 语义更新(Task 3)✓
|
||||
- **占位符**:无;每步含完整代码/命令 ✓
|
||||
- **类型一致性**:`Entry.Fingerprint`、`_current/_retired`、`ComputeFingerprintOrNull` 在测试与实现间一致;`Md5Util` 来自 Server.Host 已有的 PublishTool 引用(`GameModuleLoader.VerifySignature` 已在用)✓
|
||||
- **测试隔离**:全部改写文件的测试用临时 gamesRoot 拷贝,不碰共享 `TestGames.Root` ✓
|
||||
- **无 git**:commit 步骤替换为验证 checkpoint ✓
|
||||
@@ -0,0 +1,591 @@
|
||||
# 结算结果传递(RoomEndResult)实施计划
|
||||
|
||||
> **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:** 把小游戏的结算结果(胜者 pid + 比分文本)从 `RpsServerRoom` 经框架管道传到客户端结算弹框,替掉网关硬编码的 `WinnerPlayerId=0, ResultBlob=empty`。
|
||||
|
||||
**Architecture:** `IRoomCtx.EndRoom(RoomEndResult)` 新重载 → `Room.EndResult` 捕获 → `RoomHost.TickAll` 返回 `EndedRoom{RoomId,Result}` → `ServerLoop.OnRoomEnded` 填入既有 `RoomEndMsg` → 客户端 `MiniGameHost.ShowResultDialog` 按 blob 文本显示。零协议改动。spec:`docs/superpowers/specs/2026-07-09-room-end-result-design.md`。
|
||||
|
||||
**Tech Stack:** netstandard2.1 C#9(Framework.Shared/RPS)、net10.0 + xUnit(服务端与测试)、Unity 2022.3 UGUI(客户端,本环境仅 dotnet 编译验证)。
|
||||
|
||||
**重要约定:本仓库 `.git` 为空目录(非可用 git 仓库),用户已定"不用 git,只改代码跑测试"。所有"Commit"步骤替换为"跑测试确认绿"。**
|
||||
|
||||
**关键背景(给零上下文工程师):**
|
||||
- 一份共享源码 `Client/Assets/Framework/Shared/` 同时被客户端(Unity)与服务端(`Server/Framework.Shared.csproj` 链接编译)使用。
|
||||
- 游戏 DLL 只**消费**不**实现** `IRoomCtx`(全仓库唯一实现是 `Server/Server.Host/RoomCtx.cs`),给接口加成员不破坏已发布模块。
|
||||
- 测试夹具 `Server/Server.Host.Tests/TestGames/rps/{1,2}/` 由 `Server/build-test-games.sh` 从 RPS 源码构建,RPS.Server 改动后必须重跑该脚本,否则 RoomHost/Gateway 相关测试跑的还是旧 DLL。
|
||||
- 所有 dotnet 命令在仓库根 `D:/UD/AI/AIC#Project` 执行(路径含 `#`,bash 中加引号)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 共享接口 + RoomCtx/Room 结果管道(Server.Host 层)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client/Assets/Framework/Shared/IGameServerRoom.cs`
|
||||
- Modify: `Server/Server.Host/RoomCtx.cs`
|
||||
- Modify: `Server/Server.Host/Room.cs`(`RequestEnd`)
|
||||
- Modify: `Server/Server.Host/RoomHost.cs:41`(仅回调接线一行)
|
||||
- Test: `Server/Server.Host.Tests/RoomCtxTests.cs`、`Server/Server.Host.Tests/RoomTests.cs`
|
||||
- Modify(编译修复): `Server/Server.Host.Tests/RpsServerRoomTests.cs:49`(lambda 形参)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
`RoomCtxTests.cs` 追加两个测试(类内任意位置);同时把现有 `EndRoom_InvokesCallback`(第 39 行)和 `Broadcast_And_SendTo_RouteToOutput`(第 23 行)的 endRoom lambda 从 `() => ...` 改为 `_ => ...`(形参类型将变为 `Action<RoomEndResult>`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void EndRoom_WithResult_PassesResultToCallback()
|
||||
{
|
||||
RoomEndResult seen = null;
|
||||
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
|
||||
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
|
||||
var result = new RoomEndResult { WinnerPlayerId = 42, ResultBlob = new byte[] { 1 } };
|
||||
ctx.EndRoom(result);
|
||||
Assert.Same(result, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoom_Parameterless_PassesNull()
|
||||
{
|
||||
var seen = new RoomEndResult(); // 先放非 null,验证回调真的收到 null
|
||||
var ctx = new RoomCtx(new DeterministicRandom(1), new ManualClock(),
|
||||
new ConsoleLogger(), new InMemoryStorage(), new FakeOutput(), r => seen = r);
|
||||
ctx.EndRoom();
|
||||
Assert.Null(seen);
|
||||
}
|
||||
```
|
||||
|
||||
`RoomTests.cs` 追加(类内任意位置)一个 fake 房间与两个测试;同时把 `Build` helper(第 50 行)的 `() => room.RequestEnd()` 改为 `r => room.RequestEnd(r)`:
|
||||
|
||||
```csharp
|
||||
// 结束时带结算结果的房间
|
||||
private sealed class ResultRoom : IGameServerRoom
|
||||
{
|
||||
public RoomEndResult Result;
|
||||
private IRoomCtx _ctx;
|
||||
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx) => _ctx = ctx;
|
||||
public void OnMessage(int playerId, NetMessage message) { }
|
||||
public void OnTick(float dt) => _ctx.EndRoom(Result);
|
||||
public void OnRoomEnd() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoomWithResult_IsCapturedOnRoom()
|
||||
{
|
||||
var result = new RoomEndResult { WinnerPlayerId = 7, ResultBlob = new byte[] { 9 } };
|
||||
var (room, _, _) = Build("rr", new ResultRoom { Result = result });
|
||||
room.Start(Players);
|
||||
room.Tick(1f);
|
||||
Assert.False(room.IsActive);
|
||||
Assert.Same(result, room.EndResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndRoomWithoutResult_EndResultIsNull()
|
||||
{
|
||||
var (room, _, _) = Build("rn", new CountingRoom()); // CountingRoom 调无参 EndRoom
|
||||
room.Start(Players);
|
||||
room.Tick(1f);
|
||||
room.Tick(1f); // 第 2 tick 触发 EndRoom()
|
||||
Assert.False(room.IsActive);
|
||||
Assert.Null(room.EndResult);
|
||||
}
|
||||
```
|
||||
|
||||
`RpsServerRoomTests.cs` 第 49 行 `() => ended[0] = true` 改为 `_ => ended[0] = true`(仅编译修复,该文件的功能改动在 Task 3)。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "EndRoomWithResult|EndRoom_WithResult" 2>&1 | tail -15`
|
||||
Expected: **编译错误**(`RoomEndResult` 不存在、`Room.EndResult` 不存在)——接口未实现前编译失败即 RED。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
`Client/Assets/Framework/Shared/IGameServerRoom.cs` 全文替换为:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace XWorld.Framework
|
||||
{
|
||||
// 房间结算结果:由小游戏逻辑在 EndRoom 时给出,经网关放入 RoomEndMsg 下发客户端。
|
||||
public sealed class RoomEndResult
|
||||
{
|
||||
public int WinnerPlayerId; // 0 = 平局/无胜者(与 RoomEndMsg 语义一致)
|
||||
public byte[] ResultBlob; // 小游戏自定义结算数据;放 UTF-8 文本时框架结算弹框直接显示
|
||||
}
|
||||
|
||||
public interface IRoomCtx
|
||||
{
|
||||
IRandom Random { get; } // 权威随机
|
||||
ITimer Timer { get; }
|
||||
ILogger Logger { get; }
|
||||
IStorage Storage { get; }
|
||||
void Broadcast(NetMessage message);
|
||||
void SendTo(int playerId, NetMessage message);
|
||||
void EndRoom(); // 由小游戏逻辑请求结束本房间(无结算结果)
|
||||
void EndRoom(RoomEndResult result); // 带结算结果结束;result 可为 null,等价无参
|
||||
}
|
||||
|
||||
public interface IGameServerRoom
|
||||
{
|
||||
void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx);
|
||||
void OnMessage(int playerId, NetMessage message);
|
||||
void OnTick(float deltaTime); // 10-20Hz,内部调用 Core.Step + 广播快照
|
||||
void OnRoomEnd();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Server/Server.Host/RoomCtx.cs`:字段与构造参数 `Action _endRoom` → `Action<RoomEndResult> _endRoom`;末尾两方法:
|
||||
|
||||
```csharp
|
||||
public void EndRoom() => _endRoom(null);
|
||||
public void EndRoom(RoomEndResult result) => _endRoom(result);
|
||||
```
|
||||
|
||||
`Server/Server.Host/Room.cs`:`RequestEnd` 处(第 24-26 行)改为:
|
||||
|
||||
```csharp
|
||||
public RoomEndResult EndResult { get; private set; }
|
||||
|
||||
// 由 RoomCtx.EndRoom 回调(或宿主)触发;下一次 tick 末尾或 Start 后据此结束。
|
||||
// 多次调用以最后一次的 result 为准。
|
||||
public void RequestEnd() => RequestEnd(null);
|
||||
public void RequestEnd(RoomEndResult result) { _endRequested = true; EndResult = result; }
|
||||
```
|
||||
|
||||
`Server/Server.Host/RoomHost.cs` 第 41 行:`output, () => room.RequestEnd());` → `output, r => room.RequestEnd(r));`
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过(GREEN)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests 2>&1 | tail -3`
|
||||
Expected: 全部通过(57 旧 + 4 新 = 61 附近,0 失败)。
|
||||
|
||||
- [ ] **Step 5: 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿(Gateway 此时尚未用到新返回值,不受影响;若 Gateway 编译错说明改了不该改的东西)。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: RoomHost.TickAll 带结果返回 + 网关接线
|
||||
|
||||
**Files:**
|
||||
- Modify: `Server/Server.Host/RoomHost.cs`(`TickAll` + 新 `EndedRoom` struct)
|
||||
- Modify: `Server/Gateway/ServerLoop.cs:79-80, 405, 408-421`
|
||||
- Test: `Server/Server.Host.Tests/RoomHostTests.cs:77-90`(适配新返回类型)
|
||||
|
||||
说明:`TickAll` 返回类型变更会同时破坏 `ServerLoop` 与 `RoomHostTests` 编译,故三处一个任务内完成。结果真值(非 null)的断言在 Task 3 夹具重建后补,本任务先保证管道形状与零回归。
|
||||
|
||||
- [ ] **Step 1: 适配测试(先改测试即 RED——编译失败)**
|
||||
|
||||
`RoomHostTests.cs` 的 `TickAll_ReturnsEndedRoomIds`(第 77-90 行)改为:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TickAll_ReturnsEndedRoomIds()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.CreateRoom("E", "rps", 1, Cfg(), Players, new Capture());
|
||||
|
||||
var first = host.TickAll(10f); // 第一大 tick:TotalElapsed=10 < 60,不结束
|
||||
Assert.Empty(first);
|
||||
|
||||
var ended = new List<EndedRoom>();
|
||||
for (int i = 0; i < 10 && !ended.Exists(e => e.RoomId == "E"); i++)
|
||||
ended.AddRange(host.TickAll(10f));
|
||||
Assert.Contains(ended, e => e.RoomId == "E"); // 累计超过 60s 后该 tick 返回结束房间
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "TickAll_ReturnsEndedRoomIds" 2>&1 | tail -8`
|
||||
Expected: 编译错误(`EndedRoom` 不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现 RoomHost**
|
||||
|
||||
`Server/Server.Host/RoomHost.cs`:namespace 内(RoomHost 类之前)加:
|
||||
|
||||
```csharp
|
||||
// TickAll 的结束房间条目:携带小游戏经 EndRoom(result) 给出的结算结果(可为 null)
|
||||
public readonly struct EndedRoom
|
||||
{
|
||||
public readonly string RoomId;
|
||||
public readonly RoomEndResult Result;
|
||||
public EndedRoom(string roomId, RoomEndResult result) { RoomId = roomId; Result = result; }
|
||||
}
|
||||
```
|
||||
|
||||
`TickAll`(第 58-71 行)替换为:
|
||||
|
||||
```csharp
|
||||
public IReadOnlyList<EndedRoom> TickAll(float dt)
|
||||
{
|
||||
List<EndedRoom> ended = null;
|
||||
foreach (var kv in _rooms)
|
||||
{
|
||||
var room = kv.Value.Room;
|
||||
if (!room.IsActive) { (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.EndResult)); continue; }
|
||||
room.Tick(dt);
|
||||
if (!room.IsActive) (ended ??= new List<EndedRoom>()).Add(new EndedRoom(kv.Key, room.EndResult));
|
||||
}
|
||||
if (ended != null)
|
||||
foreach (var e in ended) ReleaseRoom(e.RoomId);
|
||||
return ended ?? (IReadOnlyList<EndedRoom>)System.Array.Empty<EndedRoom>();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 ServerLoop 接线**
|
||||
|
||||
`Server/Gateway/ServerLoop.cs` 三处:
|
||||
|
||||
第 79-80 行:
|
||||
|
||||
```csharp
|
||||
var ended = _host.TickAll(dt);
|
||||
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
|
||||
```
|
||||
|
||||
第 405 行("Start 即结束"路径,`CreateRoomForSeats` 持有 `Room` 引用):
|
||||
|
||||
```csharp
|
||||
if (!room.IsActive) OnRoomEnded(roomId, room.EndResult); // 极端:Start 即结束
|
||||
```
|
||||
|
||||
`OnRoomEnded`(第 408-421 行)替换为:
|
||||
|
||||
```csharp
|
||||
private void OnRoomEnded(string roomId, RoomEndResult result)
|
||||
{
|
||||
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
|
||||
_roomPlayers.Remove(roomId);
|
||||
var msg = new RoomEndMsg
|
||||
{
|
||||
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
|
||||
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
|
||||
};
|
||||
byte[] payload = msg.Encode();
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
int pid = players[i];
|
||||
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
|
||||
var s = _sessions.Get(pid);
|
||||
if (s != null) s.RoomId = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过(GREEN)+ 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿(此时结果恒为 null——RPS 夹具还是旧 DLL,调的无参 EndRoom;行为与改动前完全一致)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RPS 服务端产出结果 + 夹具重建 + 端到端断言
|
||||
|
||||
**Files:**
|
||||
- Modify: `Server/games-src/RPS/RPS.Server/RpsServerRoom.cs`
|
||||
- Test: `Server/Server.Host.Tests/RpsServerRoomTests.cs`(BuildCtx 捕获结果 + 2 新测试)
|
||||
- Test: `Server/Server.Host.Tests/RoomHostTests.cs`(1 新测试,夹具重建后结果非 null)
|
||||
- Test: `Server/Gateway.Tests/RpsEndToEndTests.cs`(RoomEnd 携带结算断言)
|
||||
- Run: `Server/build-test-games.sh`(重建 TestGames/rps 夹具)
|
||||
|
||||
- [ ] **Step 1: 写失败测试(单元层)**
|
||||
|
||||
`RpsServerRoomTests.cs`:`BuildCtx`(第 38-51 行)改为四元组、捕获结果:
|
||||
|
||||
```csharp
|
||||
private static (RoomCtx ctx, FakeOutput output, bool[] ended, RoomEndResult[] result)
|
||||
BuildCtx()
|
||||
{
|
||||
var output = new FakeOutput();
|
||||
var ended = new bool[1];
|
||||
var result = new RoomEndResult[1];
|
||||
var ctx = new RoomCtx(
|
||||
new DeterministicRandom(42),
|
||||
new ManualClock(),
|
||||
new ConsoleLogger(),
|
||||
new InMemoryStorage(),
|
||||
output,
|
||||
r => { ended[0] = true; result[0] = r; });
|
||||
return (ctx, output, ended, result);
|
||||
}
|
||||
```
|
||||
|
||||
现有 5 个测试的解构改四元组(多出的位置用 `_`):
|
||||
- `TwoHumans_PlayRound_ScoresUpdateByRules`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
- `TwoHumans_Reach60s_EndsRoom`:`var (ctx, output, ended, _) = BuildCtx();`
|
||||
- `HumanAndAi_AutoAdvances_AndSettles`:`var (ctx, output, ended, _) = BuildCtx();`
|
||||
- `OnRoomStart_BroadcastsInitialSnapshot`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
- `SecondChoiceFromSameSeat_IsIgnored`:`var (ctx, output, _, _) = BuildCtx();`
|
||||
|
||||
文件头部加 `using System.Text;`,追加两个新测试:
|
||||
|
||||
```csharp
|
||||
// ── 结算结果:胜者 pid + 比分文本经 EndRoom(result) 上报 ─────────
|
||||
|
||||
[Fact]
|
||||
public void Finish_ReportsWinnerPidAndScoreText()
|
||||
{
|
||||
var (ctx, _, ended, result) = BuildCtx();
|
||||
var room = new RpsServerRoom();
|
||||
room.OnRoomStart(TwoHumans, ctx);
|
||||
|
||||
// 每回合双方都出招(Rock vs Scissors → seat0 全胜),避免超时随机补招引入不确定性。
|
||||
// 每回合 10.2s,第 6 回合 reveal 后 TotalElapsed=61.2 ≥ 60 → Finished
|
||||
for (int round = 0; round < 6; round++)
|
||||
{
|
||||
room.OnMessage(10, ChoiceMsg(Choice.Rock));
|
||||
room.OnMessage(20, ChoiceMsg(Choice.Scissors));
|
||||
room.OnTick(5.1f); // 结算本回合 → Revealing
|
||||
room.OnTick(5.1f); // → 下一回合 Choosing(末回合触发 Finished)
|
||||
}
|
||||
|
||||
Assert.True(ended[0]);
|
||||
Assert.NotNull(result[0]);
|
||||
Assert.Equal(10, result[0].WinnerPlayerId); // seat0 的 pid
|
||||
Assert.Equal("最终比分 6 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_Draw_ReportsPidZeroAndDrawText()
|
||||
{
|
||||
var (ctx, _, ended, result) = BuildCtx();
|
||||
var room = new RpsServerRoom();
|
||||
room.OnRoomStart(TwoHumans, ctx);
|
||||
|
||||
// 双方每回合同出 Rock → 每回合平 → 总分 0:0 → Winner=2(平局)
|
||||
for (int round = 0; round < 6; round++)
|
||||
{
|
||||
room.OnMessage(10, ChoiceMsg(Choice.Rock));
|
||||
room.OnMessage(20, ChoiceMsg(Choice.Rock));
|
||||
room.OnTick(5.1f);
|
||||
room.OnTick(5.1f);
|
||||
}
|
||||
|
||||
Assert.True(ended[0]);
|
||||
Assert.NotNull(result[0]);
|
||||
Assert.Equal(0, result[0].WinnerPlayerId); // 平局 → 0
|
||||
Assert.Equal("平局 0 : 0", Encoding.UTF8.GetString(result[0].ResultBlob));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败(RED)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "Finish_Reports|Finish_Draw" 2>&1 | tail -10`
|
||||
Expected: FAIL——`result[0]` 为 null(RpsServerRoom 还在调无参 EndRoom)。注意:Server.Host.Tests 直接 ProjectReference RPS.Server 源码工程,不走夹具,所以这里能先红。
|
||||
|
||||
- [ ] **Step 3: 实现 RpsServerRoom**
|
||||
|
||||
`Server/games-src/RPS/RPS.Server/RpsServerRoom.cs`:
|
||||
|
||||
头部加 `using System.Text;`。
|
||||
|
||||
字段区(第 16 行 `_seatOf` 之后)加:
|
||||
|
||||
```csharp
|
||||
private readonly int[] _pidOfSeat = new int[2]; // seat->playerId(结算用;AI 也有 pid)
|
||||
```
|
||||
|
||||
`OnRoomStart` 循环体(第 23-27 行)改为:
|
||||
|
||||
```csharp
|
||||
for (int i = 0; i < players.Count && i < 2; i++)
|
||||
{
|
||||
_seatOf[players[i].PlayerId] = i;
|
||||
_pidOfSeat[i] = players[i].PlayerId;
|
||||
_state.IsAi[i] = players[i].IsAI;
|
||||
}
|
||||
```
|
||||
|
||||
`OnTick` 的结束分支(第 56-61 行)改为:
|
||||
|
||||
```csharp
|
||||
if (_state.Phase == RpsPhase.Finished)
|
||||
{
|
||||
_ended = true;
|
||||
int s0 = _state.Scores[0], s1 = _state.Scores[1];
|
||||
bool draw = _state.Winner == 2;
|
||||
_ctx.Logger.Info($"rps finished, winner seat={_state.Winner}, scores {s0}:{s1}");
|
||||
_ctx.EndRoom(new RoomEndResult
|
||||
{
|
||||
WinnerPlayerId = draw ? 0 : _pidOfSeat[_state.Winner],
|
||||
ResultBlob = Encoding.UTF8.GetBytes(draw ? $"平局 {s0} : {s1}" : $"最终比分 {s0} : {s1}")
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过(GREEN)**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests 2>&1 | tail -3`
|
||||
Expected: 全绿。
|
||||
|
||||
- [ ] **Step 5: 重建测试夹具**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && bash build-test-games.sh`
|
||||
Expected: `built rps v1 -> .../TestGames/rps/1`、`built rps v2 -> ...`、`TestGames ready.`(脚本内含 Framework.Shared.dll 不泄漏断言,失败会 exit 1)。
|
||||
|
||||
- [ ] **Step 6: 夹具层结果断言(RoomHostTests)**
|
||||
|
||||
`RoomHostTests.cs` 追加(1 真人 + 1 AI 随机对局,可能平局,故只断言 blob 非空):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TickAll_EndedRoom_CarriesResult()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.CreateRoom("R", "rps", 1, Cfg(), Players, new Capture());
|
||||
|
||||
RoomEndResult result = null;
|
||||
bool found = false;
|
||||
for (int i = 0; i < 10 && !found; i++)
|
||||
foreach (var e in host.TickAll(10f))
|
||||
if (e.RoomId == "R") { found = true; result = e.Result; }
|
||||
|
||||
Assert.True(found);
|
||||
Assert.NotNull(result); // 夹具已重建,RPS 走 EndRoom(result)
|
||||
Assert.NotNull(result.ResultBlob);
|
||||
Assert.NotEmpty(result.ResultBlob); // "最终比分 x : y" 或 "平局 x : y"
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.Host.Tests --filter "TickAll_EndedRoom_CarriesResult" 2>&1 | tail -5`
|
||||
Expected: PASS(若 FAIL 且 result 为 null,说明 Step 5 夹具没重建成功)。
|
||||
|
||||
- [ ] **Step 7: 端到端断言(Gateway.Tests)**
|
||||
|
||||
`RpsEndToEndTests.cs` 两个测试各加 RoomEnd 内容断言:
|
||||
|
||||
`TwoHumans_MatchAndPlayToRoomEnd`:第 53 行附近加变量 `RoomEndMsg c1End = null, c2End = null;`;t1 的 RoomEnd 分支(第 78-81 行)改:
|
||||
|
||||
```csharp
|
||||
else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd)
|
||||
{
|
||||
c1End = RoomEndMsg.Decode(f.Payload);
|
||||
c1RoomEnd = true;
|
||||
}
|
||||
```
|
||||
|
||||
t2 同样改(`c2End = RoomEndMsg.Decode(f.Payload);`)。末尾断言区(第 118-119 行后)加:
|
||||
|
||||
```csharp
|
||||
// 结算结果:blob 非占位(RPS 填入 UTF-8 比分文本),双端一致
|
||||
Assert.NotNull(c1End);
|
||||
Assert.NotNull(c2End);
|
||||
Assert.True(c1End.ResultBlob != null && c1End.ResultBlob.Length > 0, "RoomEnd 应携带结算 blob");
|
||||
Assert.Equal(c1End.WinnerPlayerId, c2End.WinnerPlayerId);
|
||||
Assert.Contains(":", System.Text.Encoding.UTF8.GetString(c1End.ResultBlob)); // 比分/平局文本
|
||||
```
|
||||
|
||||
(不断言具体胜者:双方每快照重发出招,回合是否踩到 Choosing 窗口受 tick 时序影响,可能随机补招/平局。确定性胜负已在 `RpsServerRoomTests` 用 ManualClock 断言。)
|
||||
|
||||
`SingleClient_AiFill_MatchAndPlayToRoomEnd`:加变量 `RoomEndMsg end = null;`,RoomEnd 分支(第 170-173 行)改 `end = RoomEndMsg.Decode(f.Payload); roomEnd = true;`,末尾断言(第 178 行后)加:
|
||||
|
||||
```csharp
|
||||
Assert.NotNull(end);
|
||||
Assert.True(end.ResultBlob != null && end.ResultBlob.Length > 0, "AI 对局的 RoomEnd 也应携带结算 blob");
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 跑 Gateway 测试确认通过 + 全 sln 回归**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Gateway.Tests 2>&1 | tail -3`
|
||||
Expected: 全绿(注意 Gateway.Tests 的 TestGames 由构建从 Server.Host.Tests/TestGames 拷贝,Step 5 已更新源头)。
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -5`
|
||||
Expected: 全绿。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 客户端结算弹框显示结果(MiniGameHost)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`(`ShowResultDialog`/`ResultTitle`/`ResultMessage`)
|
||||
- Verify: `dotnet build Client/HotUpdateGames/MiniGameRuntime.Verify`(编译验证壳,整目录 `<Compile Include>` MiniGame 源码;csproj 内 UnityEditorPath 默认值即本机 2022.3.62f3)
|
||||
|
||||
说明:本环境无 Unity,无法运行时验证;用编译验证壳保证 0 错,行为由用户 Unity Play 人工核对。客户端只消费 `RoomEndMsg`(既有类型),不引用 `RoomEndResult`,故对 `Library/ScriptAssemblies` 里的旧 Framework.Shared.dll 无依赖。
|
||||
|
||||
- [ ] **Step 1: 修改 ShowResultDialog(结算文本只解一次,标题/正文共用)**
|
||||
|
||||
`MiniGameHost.cs` `ShowResultDialog` 中,在 `_resultDialog = new GameObject(...)` 之前加一行:
|
||||
|
||||
```csharp
|
||||
string resultText = DecodeResultBlob(roomEnd.ResultBlob);
|
||||
```
|
||||
|
||||
Title/Message 两处 CreateText 调用改为传入 `resultText`:
|
||||
|
||||
```csharp
|
||||
Text title = CreateText("Title", panel.transform, ResultTitle(roomEnd, resultText), 34, FontStyle.Bold, TextAnchor.MiddleCenter);
|
||||
```
|
||||
|
||||
```csharp
|
||||
Text message = CreateText("Message", panel.transform, ResultMessage(roomEnd, resultText), 22, FontStyle.Normal, TextAnchor.MiddleCenter);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换 ResultTitle / ResultMessage**
|
||||
|
||||
```csharp
|
||||
private string ResultTitle(RoomEndMsg roomEnd, string resultText)
|
||||
{
|
||||
if (roomEnd.WinnerPlayerId == 0)
|
||||
{
|
||||
// 有结算文本 = 游戏明确上报了"无胜者"→ 平局;无文本 = 旧模块/异常结束,保持中性标题
|
||||
return string.IsNullOrEmpty(resultText) ? "游戏结束" : "平局";
|
||||
}
|
||||
if (_selfPlayerId != 0 && roomEnd.WinnerPlayerId == _selfPlayerId)
|
||||
{
|
||||
return "胜利";
|
||||
}
|
||||
return "失败";
|
||||
}
|
||||
|
||||
private string ResultMessage(RoomEndMsg roomEnd, string resultText)
|
||||
{
|
||||
// 游戏上报的结算文本(如"最终比分 2 : 1")优先;裸 pid 对玩家无意义,不再显示
|
||||
if (!string.IsNullOrEmpty(resultText))
|
||||
{
|
||||
return resultText;
|
||||
}
|
||||
return string.IsNullOrEmpty(_roomId) ? $"{_gameId}@{_version}" : $"房间 {_roomId}";
|
||||
}
|
||||
```
|
||||
|
||||
(原 `ResultMessage` 里 `"\n胜利玩家:" + roomEnd.WinnerPlayerId` 的拼接随之删除;`DecodeResultBlob` 保持不变。)
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Client/HotUpdateGames/MiniGameRuntime.Verify" && dotnet build 2>&1 | tail -5`
|
||||
Expected: `0 个错误`(0 Errors)。若报 UnityEditorPath 相关缺 DLL,传 `-p:UnityEditorPath="<本机 Unity 2022.3.62f3 Editor 目录>"` 重试。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 全量回归 + 收尾
|
||||
|
||||
- [ ] **Step 1: 服务端全量测试**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Server" && dotnet test Server.sln 2>&1 | tail -6`
|
||||
Expected: 全绿,总数 ≈ 旧基线 + 新增 8(RoomCtx 2、Room 2、RpsServerRoom 2、RoomHost 1、e2e 断言在既有测试内不增数)。
|
||||
|
||||
- [ ] **Step 2: 客户端验证壳复核**
|
||||
|
||||
Run: `cd "D:/UD/AI/AIC#Project/Client/HotUpdateGames/MiniGameRuntime.Verify" && dotnet build 2>&1 | tail -3`
|
||||
Expected: 0 错误。
|
||||
|
||||
- [ ] **Step 3: 告知用户的人工步骤(计划执行者原样转达,不代做)**
|
||||
|
||||
1. Unity Editor 打开工程让其重新编译(Framework.Shared 源码变了)。
|
||||
2. 菜单「XWorld/生成小游戏更新」重发 RPS 服务器模块到 `deploy/minigame/rps/<ver>/`(旧包不崩,但弹框无结算内容)。
|
||||
3. 客户端 `XWorld.Framework.Shared` + `XWorld.Link` 热更 DLL 重出(真机);Editor Play 本地即可验。
|
||||
4. Play 一局 RPS 核对:胜→「胜利」、负→「失败」、平→「平局」,正文比分文本,点「确定」正常退出。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- **Spec 覆盖**:§1 共享接口=Task 1;§2 宿主=Task 1+2;§3 网关=Task 2;§4 RPS=Task 3;§5 客户端=Task 4;测试章=各任务 TDD 步骤+Task 3 Step 6/7;部署影响=Task 5 Step 3。无缺口。
|
||||
- **占位扫描**:无 TBD/TODO;所有代码步骤含完整代码。
|
||||
- **类型一致性**:`RoomEndResult{WinnerPlayerId,ResultBlob}`、`Room.EndResult`、`RequestEnd(RoomEndResult)`、`EndedRoom{RoomId,Result}`、`OnRoomEnded(string,RoomEndResult)`、`ResultTitle/ResultMessage(RoomEndMsg,string)` 各任务间引用一致。
|
||||
- **确定性核对**:`Finish_ReportsWinnerPidAndScoreText` 每回合双方显式出招,无随机补招;6 回合 ×10.2s=61.2s≥60 触发 Finish,比分 6:0 确定。
|
||||
Reference in New Issue
Block a user