Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
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 DLLmd5 命名,沿用 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.txtmd5 清单)
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.LocalDirmanifest: 解析好的 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: 网络通道 MiniGameNetChannelFrameCodec 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;
// 卸载本小游戏 ABXResLoader 按 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 运行时 RSAImportParameters/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.json2a 加载约定)与客户端 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` schemagameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 `game.json` schemagameId/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 ... # ABmd5 命名)
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: Md5UtilTDD
**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: FilesManifestTDD
**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: ManifestSignerRSA 签名/验签,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.jsonTDD
**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 转 .bytesmd5 命名)+ 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. HybridCLRCompileDllCommand.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.txtmd5/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 UnityHybridCLR 热更,第 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(契约/协议)、2aRoomHost/ALC/Hello 加载范式)、2b-1Gateway/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.SharedPrivate=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; // RoundResolved0/1 胜者座位,-1 平局
public int GameWinner; // GameFinished0/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` → REDRpsLogic 不存在)。
- [ ] **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 → GREEN8 用例)。
---
## 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=1payload=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 自动出招,对局能推进并结算。
- (断言通过解码广播的快照 payloadRpsLogic.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`OnRoomStart2 座位,其一 IsAI+ 多次 OnTick→广播快照可解码、能推进到结算。证明「框架零改动加载新游戏」(与 Hello 同范式)。
- [ ] **Step 2:** GREEN。
---
## Task 5: 匹配器 MatchmakerTDD
**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 AIIsAI=truepid 为负)的 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.RoomIdAI 座位无 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=1Matchmaker 立即成形)。
- [ ] **Step 3:** GREEN,且 2b-1 既有 Gateway 测试不回归。
---
## Task 7: 端到端(真 Kestrel WS2 真人 + 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=10f6 个 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 + RPSRpsLogic 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 10Server.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 只能依赖 BCLSystem.*);若将来加第三方依赖,需同步扩展下面白名单。
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: 全 PASS51 原有 + 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: 全 PASS52 + 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 确定。
@@ -0,0 +1,307 @@
# C# 热更联机小游戏架构设计
- 日期:2026-06-18
- 状态:已通过头脑风暴评审,待编写实现计划
- 适用范围:客户端(Unity)+ 服务端(.NET)统一的 C# 热更小游戏平台
## 1. 背景与目标
在现有工程基础上,建立一套**客户端与服务端都用 C# 热更**的联机小游戏架构,支持内部团队**独立制作小游戏并上传**,客户端**动态下载小游戏资源、加载并运行**。
### 现有工程现状
- 客户端:Unity + URP,已集成 **HybridCLR**C# 热更,`Client/Assets/Base/LoadDll.cs` 负责增量下载 AOT/热更 DLL + AssetBundle,已适配 android/ios/webgl/微信小游戏)+ **XLua**Lua 热更)。
- 资源分发:AssetBundle`.unity3d`+ md5 清单(`dllfiles.txt`/`files.txt`)做增量下载。
- 已有样例:`Client/Assets/MiniGames/RockPaperScissors`(石头剪刀布联机小游戏,旧版 IMGUI + Lua,服务端 `main.lua`,含匹配 + 15 秒 AI 兜底;已按 §2.4 目录规范迁入 `MiniGames/`,待 C# 重写)。
- 服务端:`Server/` 目录为空,尚未实现。
- 现有规则(`Doc/Rule/UnityProject.md`)规定小游戏逻辑用 Lua、不用 C#——本设计将**取代该规则**。
### 已锁定的核心决策(评审确认)
1. 小游戏**全面转向 C# 热更**,弃用 Lua 小游戏路线;`RockPaperScissors` 后续用 C# 重写。
2. **权威服务端**模型:服务端动态加载每个小游戏的 C# 逻辑,跑权威对局。
3. 小游戏代码组织为**共享逻辑核心 + 两端薄层**。
4. 上传者仅限**内部可信团队**;安全从轻,以签名校验 + 版本管理为主,不上重量级进程沙箱。
5. 目标平台:**AppAndroid/iOSHybridCLR+ 微信小游戏(WASM)都要**,传输层统一 **WebSocket**
6. 联机节奏:**轻实时(状态同步 10–20Hz)+ 回合制**。
### 关键技术选型(评审确认)
- **A1**:共享核心源码一份,客户端/服务端各自编译(客户端编进 HybridCLR 热更 DLL,服务端编成普通 .NET DLL)。
- **B1**:服务端单进程 + 可卸载 `AssemblyLoadContext` 动态加载小游戏逻辑(预留将来子进程隔离 B2 的接口)。
- **C1**:复用并扩展现有 `LoadDll` + md5 清单机制,叠加"小游戏 manifest"维度,不另起炉灶。
## 2. 整体分层与小游戏组成
### 2.1 三层 + 两宿主
```
┌─────────────────────────────────────────────┐
│ 框架层 (Framework, 常驻不热更) │
│ 客户端宿主: LoadDll/AB加载/网络/UI根/小游戏生命周期 │
│ 服务端宿主: 网关/匹配/房间宿主/ALC加载器/持久化 │
│ 共享: 网络协议编解码 / 小游戏SDK接口定义 │
└───────────────┬─────────────────────────────┘
│ 通过稳定接口加载/调用 (不反向依赖小游戏)
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 小游戏·共享核心 │ │ 小游戏·客户端薄层 │ │ 小游戏·服务端薄层 │
│ <Game>.Core.dll │ │ <Game>.Client.dll│ │ <Game>.Server.dll │
│ 纯C#:规则/状态/ │ │ HybridCLR加载: │ │ .NET ALC加载: │
│ 协议/校验 │ │ 渲染/UI/输入/表现 │ │ 房间/AI/持久化 │
│ 依赖→平台能力接口 │ │ 依赖→Core+客户端SDK│ │ 依赖→Core+服务端SDK│
└─────────────────┘ └─────────────────┘ └──────────────────┘
```
**依赖方向铁律**:框架 ← 小游戏(小游戏依赖框架接口,框架永不依赖具体小游戏)。Core 只依赖纯 C# + 平台能力接口,绝不引用 `UnityEngine``System.IO`、socket 等平台 API。
### 2.2 一个小游戏发布包的组成
| 内容 | 说明 | 跑在哪 |
|---|---|---|
| `<Game>.Core.dll` | 平台无关规则/状态/协议(A1:源码一份,两端各编一次) | 客户端 + 服务端 |
| `<Game>.Client.dll` | 客户端表现层(HybridCLR 热更 DLL | 客户端 |
| `<Game>.Server.dll` | 服务端权威逻辑(普通 .NET DLL,服务端侧不打 AB,原样下发 ALC) | 服务端 ALC |
| 客户端 AB `.unity3d` 若干 | **脚本与美术统一打 AB**`<Game>.Core.dll`/`<Game>.Client.dll``.bytes`(TextAsset) 打进 AB`res/` 美术/UI/音效另打 AB(§2.4) | 客户端 |
| `game.json` 元数据 | 游戏 id、版本、入口类型名、最小框架版本、人数、AI 兜底参数等 | 两端读取 |
| `manifest`(md5 清单) | 上述文件 md5 + size + 依赖,挂到现有 `LoadDll` 体系(C1 | 客户端下载用 |
> 客户端发布产物**全部是 AB(`.unity3d`)**:热更 DLL 与美术资源都走同一套 AB 下载/版本/校验管线,避免两条管线(独立 `.bytes` vs AB)并存。服务端的 `<Game>.Server.dll`/`<Game>.Core.dll` 仍是普通 .NET DLLALC 直接 `Assembly.LoadFrom`,不打 AB)。
### 2.3 小游戏入口契约
框架定义稳定接口,小游戏实现,框架按 `game.json` 的类型名反射实例化:
- `IGameClient`:客户端薄层实现(生命周期 + 网络消息 + 帧更新)。
- `IGameServerRoom`:服务端薄层实现(房间生命周期 + 消息 + tick)。
- `IGameLogic`Core 内):纯函数式推进状态 `Step(state, input) -> (newState, events)`,两端共用做权威计算与客户端预演。
新增一个小游戏 = 写 1 个 Core + 各 1 个薄层实现接口,框架零改动。
### 2.4 客户端源码目录规范
每个小游戏的**客户端源文件统一收敛在一个目录**下,便于团队独立维护、打包工具按目录扫描、热更资源整目录打 AB:
```
Client/Assets/MiniGames/<Game>/ # 一个小游戏一个根目录(<Game> = gameId,如 RockPaperScissors
scripts/ # 客户端脚本(C# 热更源码)
Core/ # 共享核心源码(A1:一份源码,客户端编进 HybridCLR 热更 DLL,服务端 <Compile Include> 链接同一批 .cs 编 .NET DLL
Client/ # 客户端薄层:IGameClient 实现 + 渲染/UI/输入/表现
res/ # 美术 / UI / 预制体 / 音效(按 AB 目录规则组织,打成 AssetBundle
```
约定:
- **`scripts/``res/` 都参与打 AB**。`scripts/` 编出的热更 DLL`<Game>.Core.dll`/`<Game>.Client.dll`)以 `.bytes`(TextAsset) 形式打进 AB`res/` 资源按目录打 AB。客户端发布产物全是 `.unity3d`(§2.2)。
- **依赖方向**`scripts/Core` 不得引用 `UnityEngine`(纯 C#,§2.1 铁律);`scripts/Client` 可引用 `UnityEngine` + 框架客户端 SDK + Core。
- **共享 Core 一份源码**`scripts/Core` 是 Core 的**唯一源码位置**;服务端薄层工程(`Server/games-src/<Game>/<Game>.Server`)通过 `<Compile Include="...MiniGames/<Game>/scripts/Core/**/*.cs">` 链接同一批 `.cs`,杜绝两端逻辑漂移(A1)。服务端薄层(`IGameServerRoom` 实现)属服务端源码,仍留在 `Server/games-src/`,不进客户端 `MiniGames/`
- 框架加载器(`Client/Assets/Script/xmain/MiniGame/`)是**常驻框架**,不属于任何单个小游戏,不放进 `MiniGames/`
## 3. 三条核心数据流
### 3.1 发布流程(制作 → 上传 → 上架)
```
开发者本地
├─ 写 <Game>.Core / .Client / .Server (Core/Client 源码在 Client/Assets/MiniGames/<Game>/scripts/Server 在 Server/games-src/<Game>/) [§2.4]
├─ 一键构建工具(Editor菜单):
│ 1. Core 编两次 → 热更DLL(给HybridCLR) + .NET DLL(给服务端) [A1]
│ 2. Client 编成 HybridCLR 热更 DLL
│ 3. 脚本+美术统一打 ABCore/Client 热更 DLL 转 .bytes(TextAsset) 打进 AB + res/ 按目录打 AB → 全是 .unity3d [§2.2/§2.4]
│ 4. 生成 game.json + 计算各文件 md5 → 小游戏 manifest
│ 5. 全部 → 签名(防篡改)
└─ 上传到「发布服务」(内部后台)
发布服务 ├─ 校验签名 + 最小框架版本兼容
├─ 写入版本库(灰度/正式通道), 资源/DLL 落 CDN
├─ 客户端侧: 把小游戏 manifest 并入 LoadDll 的清单维度 [C1]
└─ 服务端侧: 通知房间宿主「有新版本 <Game> vN 可用」
```
关键点:**一次构建产出客户端包和服务端包**,版本号绑定,避免两端逻辑漂移。
### 3.2 客户端:进入 → 下载 → 加载 → 运行
```
玩家点击某小游戏
→ 框架查 manifest: 对比本地 md5, 增量下载缺失/变更文件 (复用 LoadDll, 断点续传/重试)
→ 校验签名+md5
→ 加载该小游戏全部 AB(.unity3d):含脚本 AB 与美术 AB
→ 从脚本 AB 取出 Core/Client 的 .bytes(TextAsset) → HybridCLR.LoadMetadataForAOTAssembly + Assembly.Load(Core, Client)
→ 反射实例化 game.json 指定的 IGameClient 入口
→ 框架建小游戏根节点/UI根, 注入 ctx(资源加载器/网络通道/退出回调)
→ IGameClient.OnEnter() 接管; OnUpdate 每帧驱动
退出: IGameClient.OnExit() → 卸载AB → 卸载小游戏根 → (DLL常驻, 内存可控)
```
微信小游戏分支:传输强制 WebSocket,代码下载走 WX 文件系统缓存。**热更代码下载需符合微信平台政策**(见 6.6 降级策略)。
### 3.3 对局流程(权威服务端 + 轻实时状态同步)
```
客户端 服务端(网关/房间宿主)
│── 请求匹配(gameId,version) ──▶ 匹配队列(按gameId+version分桶)
│ ├─ 凑齐N人 或 15s超时→AI兜底(沿用RPS规则)
│ ├─ 房间宿主: 若未加载该版本Server DLL
│ │ → 用可卸载ALC加载 <Game>.Server.dll [B1]
│ └─ new IGameServerRoom, OnRoomStart(players)
│◀──── 房间就绪/初始状态快照 ─────┤
│ │ 服务端权威循环:
│── 玩家输入(intent) ──────────▶ OnMessage → Core.Step() 推进权威状态
│ │ OnTick(10-20Hz): 生成状态快照/增量
│◀──── 状态快照/事件广播 ────────┤ (轻实时: 客户端可用Core.Step本地预演+插值)
│ IGameClient渲染表现 │
│── ... 直到结束 ──────────────▶ OnRoomEnd → 结算落库 → 通知客户端
│◀──── 结算结果 ────────────────┤ → ALC 在无房间引用时可卸载(热更新版本)
```
**权威性保证**:胜负/随机/关键状态只认服务端 `Core.Step` 的结果;客户端 `Core.Step` 仅用于预演和插值,最终以服务端快照校正。两端跑同一份 Core,天然一致。
## 4. 服务端运行时架构
### 4.1 进程内组件(单进程,B1)
```
┌──────────────────────── 服务端主进程 (.NET 8+) ──────────────────────────┐
│ [接入层] WebSocket 网关 (Kestrel/ASP.NET Core) │
│ ├─ 连接/鉴权/心跳, 统一收发框架协议帧 │
│ └─ Session 管理(playerId ↔ 连接), 断线重连窗口 │
│ [路由层] 消息路由: 框架消息(匹配/心跳/大厅) vs 小游戏消息(转给房间) │
│ [匹配服务] 按 (gameId, version) 分桶队列 │
│ ├─ 凑齐N人建房; 15s超时→请求房间用该游戏AI兜底 │
│ └─ 不跨版本匹配(避免 Core 不一致) │
│ [房间宿主 RoomHost] ★核心 │
│ ├─ GameModuleRegistry: gameId+version → 已加载的 ALC + 入口工厂 │
│ ├─ ALC 加载器: 可卸载 AssemblyLoadContext, 按需加载 <Game>.Server.dll │
│ │ + 对应 Core.dll; 注入服务端能力(IRandom权威/ITimer/日志/存储) │
│ ├─ Room 实例池: 每对局 1 个 IGameServerRoom; 统一 Tick 调度(10-20Hz) │
│ └─ 房间结束回收; 某版本无房间引用 → 标记可卸载 │
│ [基础设施] 持久化(对局结果/玩家数据) · 日志/监控 · 配置 · 发布通知订阅 │
└──────────────────────────────────────────────────────────────────────────┘
```
### 4.2 ALC 生命周期(服务端热更核心)
```
新版本发布通知到达
→ 房间宿主创建新的可卸载 ALC, 加载 <Game>.Server.dll vN+1
→ 新匹配的房间用 vN+1; 旧房间(vN)继续用旧 ALC 跑完, 互不影响 (版本共存)
→ 旧版本所有房间结束 + 无引用 → ALC.Unload() + GC → 内存回收
约束:
- Core/Server DLL 与框架之间只通过框架程序集里的接口交互
(接口类型必须在「框架默认ALC」, 不随小游戏卸载, 否则类型无法跨界)
- 小游戏 DLL 不得静态持有跨房间/跨版本的全局状态(否则阻碍卸载)
```
**不重启热更**的关键:老对局用老逻辑跑完,新对局用新逻辑,平滑切换;绝不强制踢掉在玩的对局。
### 4.3 技术栈
| 关注点 | 选择 | 理由 |
|---|---|---|
| 运行时 | .NET 8 LTS(或更新 LTS | `AssemblyLoadContext.Unload` 成熟,性能好,跨平台 |
| 接入 | ASP.NET Core Kestrel + WebSocket | 多端统一 WS(微信小游戏只能 WS),生态成熟 |
| 协议编解码 | 共享层(框架协议)+ 各小游戏 Core 自定义消息 | 两端同一份编解码,杜绝不一致 |
| 调度 | 单线程 per-room 逻辑 + 房间分配到线程池/channel | 房间内逻辑无锁、确定性好;房间间并行 |
| 持久化 | 先用轻量(SQLite/文件)跑通,接口抽象预留 MySQL/Redis | 内部阶段不过度设计 |
### 4.4 确定性与随机
权威 `IRandom` 由服务端房间持有种子,关键随机只在服务端 `Core.Step` 产生;客户端预演用服务端下发的种子/结果校正。保证"客户端预测—服务端权威"一致,也方便回放/复盘。
## 5. 关键接口契约
```csharp
// —— 共享核心层 (<Game>.Core, 纯 C#) ——
public interface IGameLogic<TState, TInput, TEvent> {
TState CreateInitial(RoomConfig cfg, IRandom rng);
StepResult<TState, TEvent> Step(TState s, TInput input, IRandom rng); // 纯函数式权威推进
byte[] Encode(TState s); TState Decode(byte[] data); // 快照编解码(两端共用)
}
// —— 框架提供的平台能力(注入给小游戏, 隔离平台差异) ——
public interface IRandom { int Next(int max); } // 服务端权威/客户端用下发种子
public interface ITimer { float Now { get; } }
public interface ILogger { void Info(string m); /* ... */ }
public interface IStorage { /* ... */ } // 仅服务端注入
// —— 客户端薄层入口 ——
public interface IGameClient {
void OnEnter(IGameClientCtx ctx); // ctx: 资源加载/网络发送/退出
void OnNetMessage(NetMessage msg);
void OnUpdate(float dt);
void OnExit();
}
// —— 服务端薄层入口 ——
public interface IGameServerRoom {
void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx);
void OnMessage(int playerId, NetMessage msg);
void OnTick(float dt); // 10-20Hz, 内部调用 Core.Step + 广播快照
void OnRoomEnd();
}
```
> 这些接口类型**定义在常驻框架程序集**(不随小游戏 ALC 卸载),保证跨边界类型一致。
## 6. 错误处理与容错
| 场景 | 处理 |
|---|---|
| 客户端下载失败/md5 不符 | 重试 + 断点续传;超阈值回大厅报错,不进半成品小游戏(复用 LoadDll 现有逻辑) |
| 签名/版本不兼容 | 拒绝加载,提示更新框架版本 |
| 小游戏 Server 逻辑抛异常 | 房间隔离:捕获→该房间安全结束并落败/重开,不拖垮主进程;记录日志告警 |
| 玩家断线 | 重连窗口内恢复 Session;超时→AI 接管或判负(按 game.json 配置) |
| ALC 卸载失败(有泄漏引用) | 监控驻留版本数;告警并保留旧 ALC,绝不强卸导致崩溃 |
| 微信小游戏热更代码政策受限(6.6) | 合规审核通道发版 / 必要时主包内置基础小游戏,热更仅增量 |
### 6.6 微信小游戏热更风险(需重点验证)
微信小游戏平台对"下载并执行远端代码"有政策约束。落地前必须验证 HybridCLR 在 WX-WASM 环境下的热更代码下载是否合规。降级策略:基础/首发小游戏内置主包,热更仅做增量与资源更新;正式发版走微信审核通道。
## 7. 安全(内部可信,从轻)
- **签名校验**:发布产物全部签名,客户端/服务端加载前验签(防中间人篡改 DLL)。
- **传输**WSSTLS)。
- **来源限定**:只加载发布服务登记过的 manifest,不接受任意 URL。
- 不上进程沙箱 / 字节码校验(内部信任,YAGNI);第 5 节的能力接口注入天然限制小游戏可触达的 API,预留将来收紧空间。
## 8. 测试策略
| 层 | 测试方式 |
|---|---|
| Core(纯 C#) | 单元测试:`Step` 确定性、编解码往返、规则边界。两端共用 Core,测一次双端受益 |
| 协议编解码 | 往返一致性 + 跨版本兼容用例 |
| 服务端房间 | 用假网络/假玩家驱动 `IGameServerRoom`,含断线/超时/AI 兜底用例 |
| ALC 热更 | 集成测试:加载 v1→建房→发布 v2→新房用 v2、旧房跑完→v1 卸载、内存回收断言 |
| 端到端 | 用 RPS 跑通:匹配→对局→结算→热更换版本 |
## 9. 渐进落地路线(每步可独立验证)
1. **框架接口与协议层**:定义第 5 节接口、网络协议编解码(共享层),先不接小游戏。
2. **服务端骨架**Kestrel WS 网关 + Session + 匹配 + RoomHost + ALC 加载器(先加载一个 hello 房间验证热更/卸载闭环)。
3. **客户端骨架**:扩展 `LoadDll` 支持小游戏 manifest 维度 + HybridCLR 加载小游戏 DLL + 生命周期接管。
4. **构建/发布工具链**:一键产出两端包 + manifest + 签名 + 上传(A1 的双编译)。
5. **试金石:用 C# 重写 RockPaperScissors**(Core 规则 + 客户端薄层 + 服务端薄层 + AI 兜底),跑通端到端 + 热更换版本。
6. **沉淀模板**:把 RPS 抽成"新小游戏脚手架模板",验证"写一个新小游戏 = 框架零改动"。
每一步对应第 8 节测试,独立验证后再进入下一步。
## 10. 待跟进 / 风险清单
### 10.1 「脚本+美术统一打 AB」改造清单(相对已实现代码的增量)
现有实现把热更 DLL 落成**独立 md5 命名的 `.bytes`**、与美术 AB 走两条管线;本设计(§2.2/§2.4)改为**DLL 以 `.bytes` 打进 AB、与美术同走一条 AB 管线**。需改造(其余模块零改动):
- **客户端目录迁移**:已完成——`RockPaperScissors` 迁入 `Client/Assets/MiniGames/RockPaperScissors/``Art→res``script→scripts`,保留 `.meta` GUID)。新增小游戏一律按 §2.4 落在 `MiniGames/<Game>/`
- **Editor 构建(`Client/Assets/Editor/MiniGamePublishMenu.cs`**:第 3 步由「DLL→.bytes 旁路 + 美术打 AB」改为「DLL→.bytes 作为 TextAsset 与 `res/` 一起 `BuildAssetBundles`」,产物全是 `.unity3d`
- **发布布局(`Server/PublishTool/PublishLayout.cs` + `GameSpec`/`GameJsonWriter`**:客户端侧不再 `CopyBytes` 单独落 DLL `.bytes`;改为把脚本 AB 与美术 AB 一并按 md5 命名落盘并登记进 `files.txt``game.json` 用 AB 名(含脚本 AB)替代 `coreDll`/`clientDll` 字段语义。同步更新 `PublishTool.Tests`
- **客户端加载器(`Client/Assets/Script/xmain/MiniGame/`**`MiniGameAssemblyLoader``File.ReadAllBytes(<dll>.bytes)` 改为从已加载 AB 取 `TextAsset` 字节再 `Assembly.Load``MiniGameDownloader` 不再特判 `.bytes`(全部条目按 AB 增量下载);`MiniGameManifest` 解析 game.json 的 AB 字段。**须 Unity Editor/真机核对**(本环境无 Unity,无法验证 HybridCLR 从 AB 加载 DLL)。
### 10.2 其他
- 微信小游戏热更代码政策合规性(6.6)——上线前必须验证。
- 现有 `Doc/Rule/UnityProject.md` 的"小游戏用 Lua"规则需更新为 C# 路线。
- HybridCLR 在 WASM 下对小游戏热更 DLL 的 AOT 泛型/裁剪约束需实测。
- ALC 卸载的内存泄漏边界(静态字段、事件订阅、未释放的 native 句柄)需在第 2 步集成测试中重点验证。
@@ -0,0 +1,91 @@
# 删除 Hello 热更小游戏 + 完善剪刀石头布(RPS)替代它 — 设计
日期:2026-06-22
状态:已与用户对齐设计决策,待用户复核本 spec
## 1. 背景与目标
`Hello` 是 C# 热更小游戏平台早期的**测试夹具**:一个每 tick 计数、3 tick 自结束、单人(playerCount=1)的极简游戏。它的真正用途是验证框架通用能力(ALC 加载/卸载、跨界类型隔离、版本引用计数与淘汰、多版本并存热更、房间生命周期、game.json 解析、网关消息流、断线重连),并通过 `#if HELLO_V2` 编出 v1/v2 两份**不同 IL** 来测"多版本并存"。
剪刀石头布(RockPaperScissors / RPS)是平台的第一个真实小游戏:
- **服务端 C# 已完整**:`RPS.Core`(规则/状态/二进制编解码/确定性随机)+ `RPS.Server`(`IGameServerRoom`,2 人对战,AI 匹配兜底)。已被 `RpsLogicTests``RpsServerRoomTests``RpsModuleLoadTests``ServerLoopMatchmakingTests``RpsEndToEndTests` 覆盖。
- **客户端缺 C#**:目前只有弃用的 Lua 实现(`Client/main.lua``Server/main.lua`,及 `scripts/` 下同名拷贝)。README 已声明 Lua 路线弃用,待 C# 重写。
**目标**:彻底删除 Hello;让 RPS 完整替代 Hello 的两个角色——(a) 框架测试夹具,(b) 端到端示例游戏(含 C# 客户端薄层)。删除后 `dotnet test Server/Server.sln` 必须保持全绿。
## 2. 设计决策(已与用户确认)
1. **不在 RPS 生产代码加任何 `#if`**。删除唯一依赖"两份不同 IL"的测试 `HotReloadEndToEndTests`(v1/v2 计数差异断言)。多版本相关的其余覆盖(引用计数、淘汰、卸载)用 **rps/1 + rps/2 同 IL 拷贝**(version 字段不同、DLL 字节相同)保留——这些测试只关心版本号与 refcount,不关心 IL 差异。
2. **删除弃用的 Lua 文件**,由 C# 客户端取代。
3. C# 客户端薄层落在 `Client/Assets/MiniGames/RockPaperScissors/scripts/Client/`(符合设计 §2.4 与 README)。
4. 本环境**无 Unity**:C# 客户端与 PC 烟雾的 Unity 部分只能用 csc 编译验证,无法 Play 核对;纯 .NET 部分用 `dotnet test` 验证。
## 3. 工作分解
### 3.1 删除 Hello 源码与产物
- 删目录:`Server/games-src/Hello/``Client/HotUpdateGames/HelloFlow.Client/`
- 删夹具:`Server/Server.Host.Tests/TestGames/hello/`(及各 `bin/.../TestGames/hello/` 运行时拷贝,会被重建,无需手动逐个删但应清理源树内的)。
- `Server/Server.Host/GameManifest.cs` 文档注释里的 Hello 示例改为 RPS。
### 3.2 构建脚本 `Server/build-test-games.sh`
- 删除 `build_variant`(Hello v1/v2)及其两处调用。
- 扩展 RPS 段:除 `rps/1` 外,再把**同一批** `RPS.Core.dll` / `RPS.Server.dll` 拷到 `rps/2`,并写 `version:2` 的 game.json(IL 完全相同,仅供版本号/淘汰/refcount 测试)。
- 保留既有 `Private=false`(Framework.Shared 不入产物)断言。
### 3.3 测试迁移(`dotnet test Server/Server.sln` 保持全绿)
按"是否已有 RPS 等价覆盖"分类处理:
| 文件 | 处理 | 说明 |
|---|---|---|
| `HotReloadEndToEndTests.cs` | **删除** | 唯一需两份不同 IL;按决策 1 移除 |
| `AlcUnloadTests.cs` | 改用 `rps` | 卸载回收内存,RPS 无等价,迁移 |
| `GameManifestTests.cs` | 改用 `rps` manifest | game.json 解析,改读 rps/1(及 rps/2 字段) |
| `GameModuleRegistryTests.cs` | 改用 `rps/1`+`rps/2` | refcount/淘汰/卸载,用同 IL 双版本 |
| `GameModuleLoaderTests.cs` | 改用 `rps`,与 `RpsModuleLoadTests` 去重 | 若与现有 RPS 加载测试重复则删冗余,保留独有断言(缺版本抛错等) |
| `RoomHostTests.cs` | 改用 `rps` | 适配 2 人(AI 补位/双 player)、对局非 3-tick 结束(用多 tick / logicalDt 推进到 Finished);投递改为发 Choice 消息 |
| `ServerLoopTests.cs` | 框架通用用例改用 `rps`,与 `ServerLoopMatchmakingTests` 去重 | 心跳/重连/过期会话/无房间帧等通用用例迁移;匹配流若已被 Matchmaking 测试覆盖则删冗余 |
| `GatewayEndToEndTests.cs` | 与 `RpsEndToEndTests` 去重 | e2e 已有 RPS 版;保留独有的重连用例(改 rps)或删冗余 |
| `MatchmakerTests.cs` | 删 hello 引用 | 已含 rps 用例,移除 hello 分支 |
| `RpsModuleLoadTests.cs` | 删 hello 引用 | 同上 |
迁移适配要点:
- RPS playerCount=2:需要真人+真人或真人+AI 成团;单测里可直接构造 2 个 PlayerInfo 或走匹配 AI 兜底。
- RPS 对局靠时间推进(60s,选 5s/亮 5s):测试用大 `dt` 或多次 tick 推进到 `RpsPhase.Finished`,避免真实 60s 等待(沿用 `GameServerHost``logicalDt` 思路)。
- 快照 opcode:RPS `SnapshotOpcode=1`(Hello 用 2),断言需相应调整。
### 3.4 RPS C# 客户端(对标 `HelloFlowGameClient`)
- 新建 `Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RPS.Client.csproj`:
- netstandard2.1 / C# 9,引用 `Framework.Shared` + `RPS.Core`(均 `Private=false`),引用 UnityEngine/CoreModule/IMGUIModule(HintPath 同 HelloFlow.Client)。
- 新建 `RpsGameClient.cs`:实现 `IGameClient`(`OnEnter/OnNetMessage/OnUpdate/OnExit`):
- `OnEnter`:建 IMGUI 视图(MonoBehaviour),状态"等待匹配"。
- `OnNetMessage`:用 `RpsLogic.Decode` 解快照,更新视图(回合数、阶段、双方比分、本回合选择/结算、最终胜负)。
- 出招:在 Choosing 阶段由按钮/快捷键触发,`PacketWriter` 写 Choice,`ctx.Send(new NetMessage(ChoiceOpcode, ...))`
- 镜像 Lua 客户端的交互语义(本地玩家固定左侧、选择锁定、计时进度、auto 标记、最终结算行)。受限于 C# 快照当前字段,展示按 `RpsState` 现有字段呈现(座位/选择/比分/胜者),不强行扩协议。
- **验证**:用 csc + Unity 引擎 DLL + Framework.Shared.dll + RPS.Core.dll 编译,要求 0 错误(沿用第 3 步的 rsp 响应文件做法)。
### 3.5 删除弃用 Lua
- 删 `Client/Assets/MiniGames/RockPaperScissors/Client/main.lua``.../Server/main.lua``.../scripts/Client/main.lua``.../scripts/Server/main.lua`(及其 `.meta`)。
- 更新 `README.md`:状态从"Lua 弃用待重写"改为"C# 客户端已落地于 scripts/Client";服务端薄层指向 `Server/games-src/RPS/`
### 3.6 PC 烟雾测试
- `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`:Hello → RPS(构建 `RPS.Server` + `RPS.Client`,产出 `server/games/rps/1/``client/minigame/rps/1/`,2 人 + AI 兜底)。
- `Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs`:把 hello 引用改 rps。
- .NET 构建段尽量验证;Unity Play 留人工核对(本环境无法验)。
## 4. 验证策略
- **主锚点**:`dotnet test Server/Server.sln` 迁移后保持全绿(当前 145/145,删 HotReload 与去重后总数会变,但必须 0 失败)。
- **客户端**:csc 编译 `RPS.Client` = 0 错误。
- **构建脚本**:`bash Server/build-test-games.sh` 成功产出 `rps/1``rps/2`,且无 Framework.Shared.dll 泄漏。
- **无法在本环境验证**(须 Unity 真机/编辑器,明确标注遗留):RPS 客户端运行时表现、PC 烟雾 Play、HybridCLR 加载、AB 打包。
## 5. 非目标(YAGNI)
- 不把 `RPS.Core` 源码迁到 `scripts/Core`(设计 §10.1 的"统一 Core 单源"是独立改造项,且本环境无法 Unity 验证,本次不动)。
- 不深化 RPS 玩法(不加新回合规则/奖励到 C# 协议层)。客户端按现有 `RpsState` 字段呈现。
- 不实现统一 AB 打包改造(设计 §10.1 待办,须 Unity)。
## 6. 风险
- 测试迁移可能暴露 RPS 在某些框架路径下的差异(如 2 人匹配、时间推进),需逐个适配断言;以 `dotnet test` 全绿为准绳。
- C# 客户端与 PC 烟雾的 Unity 行为无法在本环境证实,只能编译验证 + 标注人工核对。
@@ -0,0 +1,108 @@
# 账号鉴权(真登录 + 签名 token)设计
日期:2026-06-24
状态:已与用户确认设计,待写实施计划
## 背景与目标
当前网关 `/ws?pid=<int>` **直接信任客户端传来的明文 pid**`Server/Gateway/GameServerHost.cs:49-77`),pid 是客户端对账号做 FNV 哈希得到(`CSharpClientApp.GetStablePlayerId`)。后果:任何人可在 URL 里填任意 pid 冒充任意玩家、劫持会话(`SessionManager` 以 pid 为键,同 pid 视为重连)。这是公网上线的硬安全缺口(memory 多处记为「未做鉴权」)。
**目标**:引入真账号体系——账号+密码(PBKDF2 哈希)验证后签发 HMAC 签名 token,网关用 token 验签取**权威 pid**,杜绝伪造 pid / 冒充。
**范围(用户已定)**
- 支持:**注册新账号**(开放注册)、**账号密码登录**。
- 不做:游客登录、邀请码。
- 账号存 **SQLite 单文件库**
- token 用 **HMAC 签名、无状态**(不易主动吊销,靠过期;将来需吊销再叠加会话表)。
非目标(本期不做,列为后续):主动吊销/踢人、限流(先靠 Caddy)、找回密码、多设备管理、邮箱/手机验证。
## 架构
新增**隔离子系统** `Server/Auth/`(库 `XWorld.Server.Auth`net10.0netstandard 不要求),仅在连接/登录时被调用,**不进 tick 热路径**,与单线程 tick actor 兼容。
```
客户端 Caddy(TLS) 网关 Kestrel (:5005)
│ POST /login {user,pwd} ──HTTPS──▶ game.xworld.link ──▶ AuthEndpoints
│ ◀────────── {token, pid} ──────────────────────────── └─ AccountStore.Verify → TokenService.Issue
│ wss://…/ws?token=<token> ─WSS──▶ ─────────────────────▶ /ws 中间件
│ └─ TokenService.Verify → pid → ConnectCommand
```
### 组件与职责
**`AccountStore`SQLite**
- 依赖 `Microsoft.Data.Sqlite`(新增 NuGet)。
- 表:`accounts(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL COLLATE NOCASE, pwd_hash BLOB NOT NULL, salt BLOB NOT NULL, iter INTEGER NOT NULL, created_at INTEGER NOT NULL)`
- `int CreateAccount(string username, string password)` → 新账号 id;用户名重复抛/返回明确错误(映射 409)。
- `int? VerifyCredentials(string username, string password)` → 命中且密码正确返回 id,否则 null。
- 密码哈希:`Rfc2898DeriveBytes`PBKDF2-SHA256),每用户 16 字节随机盐,迭代数 ≥100000(存 `iter` 便于将来升级),输出 32 字节。比较用定长 `CryptographicOperations.FixedTimeEquals`
- 连接:单文件 `accounts.db`,路径由网关参数 `--authDbPath` 指定;开 WAL`AccountStore` 内部串行化写(或每次开短连接 + `busy_timeout`)。**只在 register/login 时访问**。
**`TokenService`HMAC 签名,无状态)**
- token 文本:`base64url(payload)` + `"."` + `base64url(HMAC-SHA256(secret, payload))``payload = "{pid}.{expUnixSeconds}"`v1 固定前缀便于演进,记为 `v1.{pid}.{exp}`)。
- `string Issue(int pid, TimeSpan ttl)`:默认 ttl 7 天(网关参数 `--tokenTtlHours` 可配)。
- `bool Verify(string token, out int pid)`:重算 HMAC 定长比较 + 校验未过期;任一不符返回 false。
- 密钥:32 字节,来源优先级 `--authSecret`base64> 环境变量 `XWORLD_AUTH_SECRET` > DB 同目录 `auth.key` 文件(不存在则生成随机 32 字节并以受限权限持久化)。**密钥仅服务端,绝不下发客户端**。
**网关 HTTP 端点(复用同一 Kestrel,经 Caddy 暴露在 `game.xworld.link`**
- 在 `GameServerHost` 建 app 时映射(或抽到 `AuthEndpoints.Map(app, store, tokens)`):
- `POST /register`body `{ "username", "password" }`JSON)。校验非空/长度 → `CreateAccount` → 成功签 token 返回 `{ "token", "pid" }`;重复用户名 → 409 `{ "error": "username_taken" }`;非法输入 → 400。
- `POST /login`body `{ "username", "password" }``VerifyCredentials` → 成功 `{ "token", "pid" }`;失败 → 401 `{ "error": "invalid_credentials" }`
- 端点不依赖 ServerLoop,纯同步/短异步,避免阻塞 tick。
**网关 `/ws` 改造(`GameServerHost.cs:49-77`**
- **鉴权 opt-in**(与现有 `publicKeyPem==null` 关签名同范式):`StartAsync` 新增 `authSecret`/`authDbPath`(或一个组装好的 `TokenService`/`AccountStore`)参数。
- 配置了鉴权:`/ws``?token=``TokenService.Verify` 失败/缺失 → 401,不建连接;成功取 token 内 **pid**(忽略任何 `?pid=`)。
- 未配置鉴权(本地 dev / 现有测试):保留旧 `?pid=` 路径,零回归。
- pid 自此**只来自 token**(权威,DB 账号 id)。
**`Gateway.Runner` 接线**
- 新增参数:`--authDbPath <path>``--authSecret <base64>`(可选)、`--tokenTtlHours <n>`(默认 168)。
- 生产由 `install-services.ps1``--authDbPath C:\xworld\accounts.db`(密钥走 `auth.key` 自动生成或 `--authSecret`)。`deploy/windows/install-services.ps1` 与 README 同步更新。
### 客户端(XWorld.Link 热更层)
**`AuthClient`(新增,`Client/Assets/Script/xmain/.../AuthClient.cs`**
- 新增常量 `GlobalData.ProductionAuthBase = "https://game.xworld.link"`(与 WSS 同主机,HTTP 端点经 Caddy)。
- `IEnumerator Login(user, pwd, Action<bool ok, string token, int pid, string err>)``Register(...)``UnityWebRequest.Post``GlobalData.ProductionAuthBase + "/login"`(或 `/register`)。解析 JSON `{token,pid}`
- 失败按状态码给出可读错误(409 用户名已占用 / 401 账号密码错 / 网络错)。
**`CSharpClientApp` 改造**
- `OnLoginClicked` / `OnRegisterClicked`:改为调 `AuthClient`(协程),成功后 `XData.SetString(LoginTokenKey, token)`、记 pid、设 `GlobalData.Token`,再 `EnterLobby`;失败 `ShowLoginError`
- `ConnectLobby`:用 `wss://game.xworld.link/ws?token=<token>`(替掉 `WithPlayerId`/`?pid=` 拼接)。token 从内存/`XData` 取。
- 自动登录:启动时若 `XData` 有未过期 token(客户端可只存 + 直接试连,过期由服务端 401 反馈再回登录页),跳过手填。配合已有 remember/auto UI。
- `GetStablePlayerId` 退役(pid 改由服务端下发)。
### 数据流(端到端)
1. 注册/登录:客户端 HTTPS POST → 网关验密(PBKDF2 定长比较)→ `TokenService.Issue(pid)` → 返回 `{token,pid}`
2. 连接:客户端 `wss://…/ws?token=…` → 网关 `Verify` → 取权威 pid → `ConnectCommand`
3. 安全性:伪造 pid 不可能(无服务端密钥无法签出有效 token);冒充他人需其凭据或服务端密钥;明文 pid 路径在生产关闭。
## 错误处理
- 注册:用户名空/过长/非法字符 → 400;重复 → 409;DB 写失败 → 500(日志含原因,不含密码)。
- 登录:账号不存在或密码错 → **统一 401 `invalid_credentials`**(不区分,防用户名枚举)。
- WS:缺 token / 验签失败 / 过期 → 401,不建连接、不 Submit。
- 客户端:网络失败/超时 → 提示重试;401 → 回登录页清本地 token。
## 测试(服务端 TDD,沿用 `Server.Host.Tests`/`Gateway.Tests` 套路,临时文件或内存 SQLite
- `AccountStore`:建号成功返 id;重复用户名(含大小写 NOCASE)被拒;验密——对的过、错的拒、不存在的拒;密码不以明文存(读 BLOB 断言非明文);盐不同则相同密码哈希不同。
- `TokenService`Issue→Verify 往返取回同 pid;篡改 payload/sig 任一被拒;过期被拒;换密钥被拒。
- 端点(集成,真 Kestrel + HttpClient):register→拿 token→/ws 用该 token 连上(pid 与返回一致);重复 register 409;错密码 login 401。
- WS:有效 token 建连且 pid 来自 token;缺/坏/过期 token → 401 且不建连接;未配鉴权时 `?pid=` 仍可用(回归)。
- 目标:`dotnet test Server/Server.sln` 全绿(现有用例不回归)。
## 安全默认与后续
- 全程 HTTPS/WSSCaddy,已部署)。密钥 32B 仅服务端。密码 PBKDF2-SHA256+盐+≥10万迭代+定长比较。登录失败不泄露用户名是否存在。
- **后续(本期不做)**:主动吊销/踢人(叠加 SQLite 会话表或短 token+刷新)、登录限流/锁定(Caddy 或网关计数)、找回密码、邮箱验证、token 刷新机制。
## 受影响文件清单(实施时)
- 新增:`Server/Auth/``XWorld.Server.Auth.csproj``AccountStore.cs``TokenService.cs``AuthEndpoints.cs`)、`Server/Auth.Tests/`
- 改:`Server/Gateway/GameServerHost.cs`/ws 验 token + 映射端点 + Start 参数)、`Server/Gateway.Runner/Program.cs`(参数接线)、`Server/Server.sln`(加工程)、`deploy/windows/install-services.ps1` + `README.md`
- 客户端:新增 `AuthClient.cs`;改 `CSharpClientApp.cs`(登录/注册/ConnectLobby/自动登录)、`GlobalData.cs``ProductionAuthBase`)。
@@ -0,0 +1,113 @@
# 内网发现:探测 IP 可配置化 + 移除组播
日期:2026-07-06
状态:已批准(方案 B)
前置:`2026-07-06-multicast-discovery-design.md`(组播方案,实测跨网段不通,本 spec 将其代码移除)
## 背景与问题
2026-07-06 真机实测:手机(172.30.37.x)→ 开发机(192.168.130.40)跨网段,组播 1.5s 无回复——路由器未开组播路由(IGMP/PIM),组播与广播一样出不了网段。跨网段唯一可靠路径是**单播已知 IP**,但该 IP 目前写死在代码里(`DevExtraProbeIps`),换机器/换网段要改代码重打包。
## 目标
1. **删除全部组播代码**(实测无效,不留死代码)。
2. 探测 IP 改为**设备本地配置**:`persistentDataPath/dev_server.txt`
3. 底包发现失败且需要目标时,**真机上弹输入框**让开发者直接输入开发机 IP,探测成功自动写入配置,之后重启不再询问。
4. 删除写死的 `192.168.130.40`,完全由配置文件接替。
## 非目标
- 不做通用设置界面;输入框仅服务于内网发现这一场景。
- 不支持多 IP / IP:端口(端口固定 48923);配置文件只存一行 IPv4。
- 热更层不弹框(底包阶段已处理,热更层只复用配置)。
- 不改发现协议(magic/端口/编解码不变)。
## 行为规格
### 底包启动流程(`LoadDll.CoLocalDiscoverThenDownload`,仅 bLocal 内网模式)
```
第1轮 Discover(1500ms, targets=广播+loopback+定向广播+文件IP(若有)) ─成功→ 用发现的 CDN 根,照旧 DownLoadAssets
│失败
挂 DevServerPrompt(输入框预填 DevServerConfig.Load() 旧值,可为空) ←──────────┐
│[确定](输入 IP) │
↓ │
IP 格式非法 → 弹框内红字"IP 格式不对",留在弹框 ─────────────────────────────┤
第2轮 Discover(1500ms, 含输入IP) ─失败→ 弹框内红字"探测失败",留在弹框可改可重试┘
│成功
DevServerConfig.Save(输入IP) → 销毁弹框 → 照旧 DownLoadAssets
任意时刻 [跳过] → UpdateServer 回退 http://127.0.0.1:15081/...(现状行为)
```
- 文件**有**记录且第 1 轮失败(开发机换 IP 等)→ 同样弹框,预填旧值(用户确认的行为)。
- 弹框期间加载流程等待,不超时。
- 探测成功但成功来源无法区分(广播/文件 IP),第 1 轮成功**不**写文件——只有弹框输入并验证成功才写。
### 热更层(`DevServerDiscovery`,GameLauncher/DevServerSwitchUI 用)
- 构造探测目标时读 `DevServerConfig.Load()`,非空则加入 targets。不弹框。调用方零改动。
## 组件设计
### 1. `Client\Assets\Base\DevServerConfig.cs`(新,静态类,全局命名空间)
- `static string FilePath``Application.persistentDataPath + "/dev_server.txt"`
- `static string Load()`:读首行 → Trim → `IPAddress.TryParse` 通过才返回,否则 null;文件不存在/IO 异常 → null(静默)
- `static void Save(string ip)`:整文件写入单行;IO 异常静默吞
- **仅底包使用**。核实发现 `XWorld.Link.asmdef` 引用列表里没有 Base 程序集,热更层引用不到;为 dev 工具改 asmdef 引用不值当,热更层按本项目"内联+两边同步"惯例(协议编解码已如此)在 `DevServerDiscovery` 内联同语义的最小读取实现
- **主线程限制**:`Application.persistentDataPath` 不能在后台线程访问,`Load()/Save()` 只能在主线程调;发现线程需要的值由调用方在主线程读好传入/存字段
### 2. `Client\Assets\Base\DevServerPrompt.cs`(新,MonoBehaviour,全局命名空间)
- 纯 IMGUI(OnGUI),风格参照热更层 `DevServerSwitchUI` 的 box/横排布局
- 字段:`string InputIp`(初始 = 预填值)、`State state`(`Waiting | Confirmed | Skipped`)、`string Error`(非空则红字显示)
- OnGUI:提示语("内网发现失败,请输入开发机 IP")+ TextField + [确定] [跳过]
- [确定] → `state = Confirmed`(探测由外部做,失败后外部把 `Error` 置上并把 state 拨回 Waiting)
- [跳过] → `state = Skipped`
- 不做网络、不做文件 IO——只是输入框
### 3. `Client\Assets\Base\LocalCdnDiscovery.cs`(改)
- 删:组播常量/TTL/逐网卡发送/`GetLocalIPv4Addresses`/相关注释、`DevExtraProbeIps` 整个字段
- 改:`Discover(string token, int durationMs)``Discover(string token, int durationMs, string extraProbeIp = null)`;`extraProbeIp` 能 TryParse 则加入 targets
- 调用方(LoadDll)自行决定传文件值还是弹框输入值
### 4. `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`(改)
- 删:同上组播相关、`DevExtraProbeIps`
- 加:内联 `ReadConfiguredProbeIp()`(读 dev_server.txt 首行+IPv4 校验,与 `DevServerConfig.Load()` 语义一致,见组件 1 的说明);**构造函数**(主线程)里读取存入字段,`Run()`(后台线程)构造 targets 时使用
### 5. `Client\Assets\Base\LoadDll.cs`(改)
- `CoLocalDiscoverThenDownload` 按上面流程图重写:两轮发现 + 弹框等待循环;弹框对象 `gameObject.AddComponent<DevServerPrompt>()`,结束后 `Destroy`
- 第 1 轮把 `DevServerConfig.Load()` 传入 `Discover``extraProbeIp`
### 6. 组播代码删除清单
- `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`:删 `MulticastGroup` 常量及注释
- `Server\Gateway\DevDiscoveryResponder.cs`:删 `JoinMulticastOnAllNics` 方法、构造函数调用行、`using System.Net.NetworkInformation;`
- `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`:删 `MulticastQuery_ReceivesReply`
- 两份客户端:删 TTL 设置、组播端点/网卡列表构建、逐网卡发送循环、零网卡兜底、`GetLocalIPv4Addresses`,注释还原为"单播兜底"语义(指向配置文件而非写死 IP)
## 错误处理
- 文件 IO、IP 解析:全部静默容错,失败等同"无配置"
- 弹框输入非法 IP:[确定] 直接在弹框显示"IP 格式不对",不发起探测
- 探测线程异常:沿用现有 catch-all,视为失败回到弹框
## 测试与验收
1. `dotnet test Server/Server.sln` 全量通过(删组播测试后)
2. 编辑器同机:loopback 一轮即中,弹框不出现,行为与改前一致
3. 真机跨网段首启:第 1 轮失败 → 弹框 → 输入 192.168.130.40 → 探测成功 → 写文件 → 正常加载
4. 真机重启:文件已有记录,第 1 轮直接命中,不弹框
5. 开发机换 IP:第 1 轮失败 → 弹框预填旧值 → 改新 IP → 成功覆写
6. [跳过]:回退 127.0.0.1,与现状一致
## 风险
- IMGUI TextField 在个别 Android 输入法上体验一般(dev 工具可接受;Unity 移动端 IMGUI 会自动唤起软键盘)
- 底包改动须重打 APK 才能上真机(部署铁律,验收时注意)
@@ -0,0 +1,87 @@
# 局域网服务发现:组播叠加设计
日期:2026-07-06
状态:已批准(方案 A)
## 背景与问题
真机内网联调依赖 UDP 发现机制找到开发机上的网关/CDN 地址。当前客户端探测目标为:
`255.255.255.255` + `127.0.0.1` + 各网卡子网定向广播 + 写死的开发机 IP `192.168.130.40`
定向广播默认不被路由器转发(RFC 2644,已实测不通),跨网段场景只能靠写死 IP 兜底——换机器/换网段就要改代码,不通用。
## 目标
- 新增组播探测路径,使发现请求**有机会**跨网段到达开发机(取决于路由器是否开启组播路由,当前网络环境未验证)。
- 纯增量改动:现有广播/loopback/写死 IP 路径全部保留,任何一条路通即发现成功;组播不通也不比现状差。
## 非目标
- 不移除写死 IP(实测组播跨网段可行后再删)。
- 不做探测 IP 可配置化(若组播实测失败,作为后续备选方案)。
- 不改发现协议本身(magic "XWDL"、端口 48923、编解码格式均不变)。
## 关键技术决策
| 决策 | 取值 | 理由 |
|---|---|---|
| 组播组地址 | `239.192.48.92` | 239.192.0.0/14 为组织本地范围(RFC 2365),专为跨子网管理域设计;239.255.0.0/16 是本地范围,路由器可能限制其跨网段 |
| 端口 | `48923`(复用现有) | 服务端同一 socket 可同时收单播/广播/组播,零额外监听 |
| 客户端组播 TTL | `8` | 组播默认 TTL=1 出不了本网段;8 足够覆盖内网多级路由,又不至于扩散过远 |
| Android MulticastLock | 不需要 | 客户端只**发**组播、**收**单播回复;MulticastLock 只影响收组播 |
## 改动清单
### 1. 协议常量 `XWorld.Framework.Protocol.DevDiscovery`
新增:
```csharp
public const string MulticastGroup = "239.192.48.92";
```
### 2. 服务端 `Server\Gateway\DevDiscoveryResponder.cs`
构造函数中,绑定 `0.0.0.0:48923` 后,枚举本机所有 Up 状态、非 Loopback 的 IPv4 网卡,对每块网卡执行:
```csharp
_udp.JoinMulticastGroup(groupAddr, nicIPv4Addr);
```
- 默认 `JoinMulticastGroup(group)` 只加入默认网卡,多网卡开发机(有线+无线/虚拟网卡)会漏收,故须逐网卡 join。
- 逐网卡 try/catch,任何一块失败不中断其余(与客户端 `GetDirectedBroadcasts` 的容错风格一致)。
- 收包与回复逻辑零改动:组播查询的 `RemoteEndPoint` 是发送方单播地址,现有单播回复天然可用。
### 3. 客户端热更层 `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
- socket 初始化后设置 `MulticastTimeToLive = 8`
- 组播端点**不加入** `targets` 通用列表(避免只走默认路由网卡),而是在每轮重发中单独处理:逐 Up 状态 IPv4 网卡设置 `IP_MULTICAST_IF`(`SocketOptionName.MulticastInterface`,值为网卡 IPv4 地址字节)后向 `239.192.48.92:48923` 发一次,逐网卡 try/catch。
- 手机通常单网卡,此逻辑主要覆盖编辑器/PC 多网卡场景。
- 现有 targets(广播、loopback、定向广播、`DevExtraProbeIps`)全部不动;`DevExtraProbeIps` 注释更新:组播跨网段实测通过后可删。
### 4. 客户端底包 `Client\Assets\Base\LocalCdnDiscovery.cs`
与热更层同步:内联 `MulticastGroup` 常量(Base 程序集不能引用热更程序集,沿用现有"改协议时两边须同步"约定),发送逻辑同第 3 条。
## 错误处理
join / 设组播网卡 / 发送失败全部静默吞掉——发现机制是"多路齐发、谁通算谁",单路失败不影响整体,与现有代码风格一致。
## 测试
`Server\Gateway.Tests\DevDiscoveryResponderTests.cs` 新增一条:
- 客户端测试 socket 设置 `MulticastLoopback = true``MulticastTimeToLive = 1`,向 `239.192.48.92:48923` 发查询,断言收到合法回复(验证响应器 join 组播组 + 组播收包路径)。
- 现有单播/广播测试不动。
## 验收标准
1. 服务端/客户端单元测试通过。
2. 编辑器同机自测:发现日志中出现组播目标,发现成功。
3. 真机同网段:发现成功(组播或广播任一路径)。
4. 真机跨网段实测:若组播路径通 → 后续任务删除 `DevExtraProbeIps` 写死 IP;若不通 → 保留写死 IP,评估"探测 IP 可配置化"方案。
## 风险
- **组播路由未验证**:公司路由器可能未开 PIM/IGMP Proxy,组播出不了网段。缓解:纯增量设计,失败无损现状。
- **部分 Android WiFi 驱动丢组播发包**:概率低(丢的主要是收方向),且广播路径仍在。
@@ -0,0 +1,237 @@
# 小游戏打包流程:Unity 菜单「XWorld/生成小游戏更新」设计
日期:2026-07-07
状态:已与用户逐段确认(§1-§4
## 0. 目标与范围
一条菜单驱动的发布流水线,对选定的小游戏:
- 客户端:打**代码热更 AB**DLL 以 .bytes/TextAsset 进 AB)与**资源热更 AB**,落到
`CDN/minigame/<gameId>/<version>/<platform>/`,供多端下载更新(本地 `Cdn.Runner` 或外网 CDN 同一布局)。
- 服务器:`Server/games-src/<对应目录>` 编译出普通 DLL,落到
`deploy/minigame/<gameId>/<version>/`,整目录上传外网服务器即可作为网关 `--gamesRoot` 的 games 根热更运行。
- `Client/Assets/MiniGames` 下有多个小游戏目录时,先弹窗选择其一(单个也弹窗,用于确认版本/平台)。
本设计同时落地 2026-06-22 的「统一 AB」决策(设计 §10.1):客户端发布产物全部为 `.unity3d`
弃用裸 `.bytes` 直下双管线,并同步改造客户端运行时消费端。
已确认的决策:
- 代码热更形态:**落地统一 AB**DLL .bytes 进代码 AB,运行时同步改造)。
- 平台维度:**加 `<platform>/` 子目录,一次菜单可勾选多平台构建**。
- 版本号:**自动递增(扫 CDN 已有版本取 max+1)+ 弹窗可改**,两端共用同一 version。
- 元数据:**每游一份 `publish.json`**(解决 RockPaperScissors ↔ RPS ↔ rps 名称映射)。
- 签名:**可选**——约定私钥路径存在则签,否则空 sig + 警告。
- 实现架构:**方案 BEditor 编排 + 复用 PublishTool,新增瘦 CLI 壳)**。
## 1. 产物布局与格式
### 1.1 publish.json(每个小游戏一份,驱动全流程)
`Client/Assets/MiniGames/<Game>/publish.json`
```json
{
"gameId": "rps",
"serverSrcDir": "RPS",
"serverProject": "RPS.Server/RPS.Server.csproj",
"serverAssembly": "RPS.Server.dll",
"serverEntryType": "RPS.Server.RpsServerRoom",
"clientProject": "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj",
"coreDll": "RPS.Core.dll",
"clientDll": "RPS.Client.dll",
"clientEntryType": "RPS.Client.RpsGameClient",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 10
}
```
- `serverSrcDir` 相对 `Server/games-src/``serverProject` 相对 `serverSrcDir`
`clientProject` 相对仓库根。
- 菜单只把「含 publish.json 的目录」视为可发布小游戏;新游戏拷模板改字段即接入。
### 1.2 客户端产物(每平台一份):`CDN/minigame/<gameId>/<version>/<platform>/`
```
game.json gameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion
+ 新字段 "codeAb": "code.unity3d" + "assets": ["res.unity3d"]
files.txt {'code.unity3d','<md5>',size}, {'res.unity3d','<md5>',size},
files.txt.sig 有私钥则 RSA 签名,否则空文件(沿用两端"未配置则跳过"语义)
code_<md5>.unity3d 代码 AB<coreDll>.bytes + <clientDll>.bytes 两个 TextAsset
res_<md5>.unity3d 资源 AB:整个 res/ 目录打一个包(assets 数组支持以后拆多包)
```
- 平台名沿用底包惯例:`pc / android / ios / webgl`(与 `LoadDll.sPlatform`
`CDN/XWorld/<platform>/` 一致)。
- 线上文件 md5 命名(`name_<md5>.ext`),files.txt 登记逻辑名——与现有
`MiniGameDownloader.Md5Name` 推导一致。
- 边界:`res/` 不存在或为空时跳过资源 AB`assets` 为空数组(仅发代码 AB)。
- **不做 AES 加密**(minigame 链路现状如此,与 XWorld 底包 AB 加密体系无关)。
### 1.3 服务器产物:`deploy/minigame/<gameId>/<version>/`
```
<serverAssembly> / <coreDll> 普通 DLL(不含 Framework.Shared.dllPrivate=false 保证)
game.json server 视图(serverAssembly/serverEntryType/playerCount/tickRateHz/minFrameworkVersion
files.txt + files.txt.sig 供 GameModuleLoader 配公钥后加载前验签
```
结构与网关 `--gamesRoot` 消费的 `<id>/<ver>/` 完全一致。
### 1.4 配套修正:Core 单源
现状问题:`RPS.Client.csproj``<Compile Include="scripts~\**\*.cs">` 把 Core+Client
全编进 RPS.Client.dll(内嵌 Core 类型副本,下载的 RPS.Core.dll 实为冗余);而服务器
`RPS.Core.csproj` 编译的是 `Server/games-src/RPS/RPS.Core/` 下另一份已发生格式漂移的拷贝。
两端 Core 一旦真漂移即确定性不同步。
修正:
- `Server/games-src/RPS/RPS.Core/` 删除重复 .cscsproj 改
`<Compile Include>` 链接 `Client/Assets/MiniGames/RockPaperScissors/scripts~/Core/**`
(恢复「Core 唯一源码位在客户端 scripts~/Core」的原设计)。
- `RPS.Client.csproj` 改为只编 `scripts~/Client/**` + `ProjectReference` RPS.Core
Private=false),Client.dll 不再内嵌 Core 副本;发布用的客户端 Core.dll 取自
RPS.Core 构建产物(与服务器同一 IL)。
## 2. 构建编排(Editor 菜单 + PublishTool.Cli
### 2.1 Editor 侧
重写 `Client/Assets/Editor/MiniGamePublishMenu.cs`(替换 stub 菜单「XWorld/生成小游戏」),
新增 `MiniGamePublishWindow`EditorWindow):
- **游戏选择**:扫 `Assets/MiniGames/*/publish.json`;多个则列表选择,仅一个预选。
- **版本号**:默认 = 扫 `CDN/minigame/<gameId>/` 下数字目录取 max+1(无则 1),可手改;
覆盖已有版本前弹确认。
- **平台勾选**PC/Android/iOS/WebGL,默认勾选当前激活 BuildTarget 对应项。
- **生成按钮**:执行 §2.2 流水线,每步失败 `DisplayDialog` 报错中断。
### 2.2 流水线
```
1. dotnet build Server/games-src/<serverSrcDir>/<serverProject> -c Release
→ <serverAssembly> + <coreDll>
2. dotnet build <clientProject> -c Release
→ <clientDll>ProjectReference 引用步骤 1 的 Core 工程产物)
3. 拷 Core.dll/Client.dll → Assets/MiniGameStaging/code/*.dll.bytes
AssetDatabase.Refresh 导入为 TextAssetstaging 必须在 Assets 下且**不能带 `~` 后缀**——
`~` 目录 Unity 不导入、资产进不了 AB;用完即删含 .meta)
4. 对每个勾选平台:
BuildPipeline.BuildAssetBundles(平台临时目录,
[ code.unity3d ← 两个 .bytes 资产,
res.unity3d ← AssetDatabase 枚举 Assets/MiniGames/<Game>/res/ 下全部资产 ],
ChunkBasedCompression, 对应 BuildTarget) ← 不切激活平台,直接传 target
5. 写 resolved-spec.jsonpublish.json 字段 + version + AB 文件名列表)到临时目录
6. Process 调 PublishTool.Cli(惯例同 XWorldUtil 起 dotnet 的方式):
server:调一次 → 临时 out → 成功后原子 Directory.Move 入 deploy/minigame/<id>/<ver>/
client:每平台一次 → 临时 out → 成功后移入 CDN/minigame/<id>/<ver>/<platform>/
7. 清理 staging;汇总日志 + 完成对话框(列出全部落位路径、是否已签名)
```
先落临时目录、成功后整体 Move 的原因:`CDN/` 可能正被 `Cdn.Runner` 对外提供下载,
避免半成品被客户端拉走。
### 2.3 PublishTool 侧(.NET,本环境可 dotnet test 回归)
- **`PublishLayout` 改造**:拆 `PublishServer(srcDir, outDir, spec, key?)`
`PublishClient(srcDir, outDir, spec, key?)`;客户端侧删除 `CopyBytes` 双 .bytes 逻辑,
改为登记 `codeAb + assets` 各 ABmd5 命名落盘 + files.txt 登记逻辑名)。
- **`GameSpec` / `GameJsonWriter`**:加 `CodeAb` 字段;`WriteClient` 输出 `codeAb`
- **新增 `Server/PublishTool.Cli`**(瘦控制台,入 Server.sln):
`--mode client|server --src <dir> --out <dir> --spec <resolved-spec.json> [--key <pem>]`
- **签名**:约定私钥 `Tools/keys/minigame-private.pem`gitignore);Editor 检测到则传
`--key`,否则 CLI 写空 sig 并在输出中警告「未签名」。
- **`PublishTool.Tests` 同步更新**:新布局断言(codeAb 进清单、无 .bytes 散文件、
双端目录形状、无 key 时空 sig)。
## 3. 客户端运行时改造(XWorld.Link
- **`MiniGameManifest`**:解析新字段 `codeAb`(校验非空);`assets` 语义变为
「资源 AB 文件名列表」。
- **`MiniGameDownloader`**`RemoteBase` 加平台段 →
`<cdnBase><gameId>/<version>/<platform>/`(平台名以编译宏判定,热更层内新增小工具函数,
不引用底包 LoadDll)。下载逻辑零改动(按 files.txt 通用增量下载)。
`LocalDir` 不加平台段(设备平台固定)。
- **`MiniGameAssemblyLoader`**:从 `LocalDir + codeAb`(本地为明文件名)
`AssetBundle.LoadFromFile``LoadAsset<TextAsset>("<coreDll>.bytes"/"<clientDll>.bytes")`
`Assembly.Load`Core 先于 Client)→ `AB.Unload(false)`
找不到 TextAsset 时报清晰错误(列出 AB 内实际资产名)。
- **资源 AB 加载**:新增 `MiniGameAssetLoader : IAssetLoader`——按需
`AssetBundle.LoadFromFile(LocalDir + 资源AB)`,缓存已开包,按资产路径 `LoadAsset`
小游戏退出统一 `Unload(true)``MiniGameHost` 注入 `GameClientCtx` 的 loader 由
`UnityAssetLoader`(走 XResLoader 大厅管线,不认识 minigame AB)换成它,并在
`ExitGame` 补上此前 TODO 的 AB 卸载(MiniGameHost.cs:111)。
## 4. 错误处理、测试与配套
### 4.1 错误处理
- Editor 流水线每步检查退出码/产物存在性,失败即 `DisplayDialog` + 保留日志,
临时目录不移入 CDN/deploy(无半成品)。
- 覆盖已有版本目录:确认弹窗后先删旧目录再 Move。
- 运行时:沿用 Fail/Error 路径;新增错误信息注明「需要 codeAb 格式,旧 .bytes 包不再支持」。
### 4.2 测试与验证
- **本环境可实证**PublishTool 新布局 + Cli 的 xUnit`dotnet test Server/Server.sln`
全量回归);两端 csproj 改造后 `dotnet build` 0 错;客户端改动照惯例 csc 编译验证。
- **须 Unity 人工核对**(本环境无 Unity):EditorWindow 交互、BuildAssetBundles
跨平台产出、Editor Play 端到端(菜单生成 → Cdn.Runner 供 CDN → 网关
`--gamesRoot deploy/minigame` → 进 RPS 对局)。
### 4.3 配套调整
- **删除 `Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1`**:其裸 .bytes 产物格式
运行时不再支持,且 ps1 打不了 AB;README 改指菜单。
- **本地开发网关改指 `deploy/minigame`**`XWorldUtil.MINI_GAME_SERVER_GAMES_ROOT`
`../../Build/PcMiniGameSmoke/server/games` 改为指向仓库 `deploy/minigame`——
菜单产物直接被 Play 自动起的本地网关消费,本地/外网同一份服务器产物布局;
目录缺失提示改为「先跑 XWorld/生成小游戏更新」。
- `.gitignore``Assets/MiniGameStaging/``Tools/keys/`CDN/deploy 产物默认不 ignore
当前 `.git` 不可用、仓库无 .gitignore,此条待启用 git 后生效)。
- WebGL 注意:`AssetBundle.LoadFromFile` 在 WebGL 不可用,运行时按平台走
`LoadFromMemory(File.ReadAllBytes(...))` 回退(微信 WASM 实测仍是已知开放风险)。
### 4.3.1 修订(2026-07-07,用户定):本地网关无条件启动,发布即生效
原 §4.3 给本地网关加的「`deploy/minigame` 下须有 game.json 才启动」检测与热更架构矛盾——
服务器本就支持运行中热更(`ServerLoop.BuildGameList` 每次请求重扫目录;
`GameModuleRegistry.Acquire` 建房时按需加载,新版本自动淘汰旧版本),不应做启动期存在性检测。
- `XWorldUtil` 删除 `HasMiniGameServerPackage()` 及其两处调用:`CanOpenLocalServer`
只看网关 Runner DLL 存在;`StartMiniGameGatewayRunnerProcess` 删除
「PC minigame package not found」弹框分支。
- 启动前 `Directory.CreateDirectory(GetMiniGameServerGamesRoot())`(幂等)保证
`--gamesRoot` 有效;目录下暂无任何 `*/*/game.json` 时仅 `Debug.Log` 提示
「可随时用菜单发布,运行中即生效」,不打断。
- ~~已知边界:同版本覆盖重发会因 `LoadFromAssemblyPath` 文件锁失败~~(已被 §4.3.2 消除)。
- 大厅列表为实时扫描结果,客户端重新拉列表即见新游戏,不做推送(YAGNI)。
### 4.3.2 修订(2026-07-07,用户定方案 B):字节加载去锁 + 内容指纹自动失效
消除"同版本覆盖重发被文件锁挡住"的问题,使覆盖重发也**发布即生效**(新房间用新代码,
已入房玩家打完旧代码):
- **GameModuleAlc 字节加载**`LoadEntry` 与私有依赖解析两处 `LoadFromAssemblyPath`
改为 `File.ReadAllBytes` + `LoadFromStream`——加载后不持有任何文件句柄,覆盖/删除
目录永远成功;`Unload()` 仍惰性异步(无锁故无需强制 GC)。已知约束:字节加载的
Assembly `Location` 为空,小游戏 DLL 不得依赖它。验签照旧不受影响。
- **GameModuleRegistry 内容指纹失效**`ComputeFingerprint(moduleDir)` = 目录下全部
`*.dll`+`game.json` 按名 Ordinal 排序拼 `name=md5;` 再整体 md5。结构改为
`_current`key=`gameId@version`Entry 加 Fingerprint+ `_retired` 列表。
`Acquire` 命中时重算磁盘指纹:相同 → 计数++返回;目录读不到(覆盖交换瞬间窗口)→
视为未变化用缓存;不同 → 先加载新模块(失败旧条目原样保留),成功后旧条目标
Superseded(引用 0 即卸载,否则挪 `_retired`),新条目入 `_current`
`Release` 改按模块实例识别(先 `_current` 同实例,否则 `_retired` ReferenceEquals)。
`RefCount/IsLoaded` 语义指 `_current`。生产环境版本不可变、指纹恒命中,行为零变化。
- 测试(TDD):字节加载后磁盘 DLL 可删/可覆盖;同 key 内容变化 → 新实例 + 旧条目归零
后弱引用 GC 断言卸载;新旧并存期各自 Release 正确;指纹未变命中缓存;现有
Registry/AlcUnload/RoomHost 测试零回归。
- 配套:PcMiniGameSmoke README 的文件锁注意事项改为"覆盖重发即生效"语义说明。
### 4.4 兼容性说明
- 旧格式(裸 .bytes、无平台段)的既有 CDN 产物在运行时改造后不再可用;当前仅 RPS 一款、
由本流水线重新生成即可,无迁移负担。
- 服务器端 game.json/DLL 布局不变,网关/GameModuleLoader 零改动。
@@ -0,0 +1,109 @@
# 结算结果传递(RoomEndResult)设计
日期:2026-07-09
状态:已与用户确认(方案 A:`IRoomCtx.EndRoom` 加带结果重载)
## 背景 / 问题
对局结束后框架结算弹框只能显示"游戏结束 + 房间号":网关 `ServerLoop.OnRoomEnded` 硬编码
`RoomEndMsg { WinnerPlayerId = 0, ResultBlob = empty }`(ServerLoop.cs:412),而游戏逻辑
(`RpsServerRoom`)的胜负与比分只写了日志,框架 `IRoomCtx.EndRoom()` 没有传结果的通道。
这是既有占位欠账(见 minigame 进度备忘"`RoomEndMsg.WinnerPlayerId=0` 是占位")。
协议层 `RoomEndMsg`(FrameworkMessages.cs:219)早已有 `WinnerPlayerId` + `ResultBlob`
两字段,客户端 `MiniGameHost.ShowResultDialog` 也已按这两字段渲染——缺的只是服务端从
游戏逻辑到网关的管道。**零协议改动。**
用户确认的结算内容:**胜负 + 比分**(标题胜利/失败/平局,正文最终比分文本)。
## 设计
### 1. 共享接口(`Client/Assets/Framework/Shared/`)
(实施修订:`RoomEndResult` 按仓库 DTO 惯例落在 `GameTypes.cs`,接口重载在 `IGameServerRoom.cs`。)
```csharp
public sealed class RoomEndResult
{
public int WinnerPlayerId; // 0 = 平局/无胜者(与 RoomEndMsg 语义一致)
public byte[] ResultBlob; // 小游戏自定义结算数据;约定:放 UTF-8 文本时框架弹框直接显示
}
public interface IRoomCtx
{
// ...现有成员不变...
void EndRoom(); // 保留:等价 EndRoom(null)
void EndRoom(RoomEndResult result); // 新增
}
```
兼容性:游戏 DLL 只**消费**不**实现** `IRoomCtx`,给接口加成员不破坏已发布旧模块
(旧模块调无参版 → 结果为 null → 弹框行为同现状)。实现方仅框架内 `RoomCtx` 与测试
fake,一起改。不使用默认接口方法(避开 HybridCLR/mono DIM 运行时风险)。
### 2. 服务端宿主(`Server/Server.Host/`)
- `RoomCtx`:构造参数 `Action endRoom``Action<RoomEndResult>`;
`EndRoom()` → 回调(null);`EndRoom(result)` → 回调(result)。
- `Room`:`RequestEnd(RoomEndResult result)` 存入 `public RoomEndResult EndResult`
属性(多次调用以最后一次为准);保留无参 `RequestEnd()` = 传 null。
- `RoomHost.TickAll` 返回 `IReadOnlyList<string>``IReadOnlyList<EndedRoom>`:
`public readonly struct EndedRoom { public string RoomId; public RoomEndResult Result; }`,
`ReleaseRoom` 前捕获 `room.EndResult`。现有使用 TickAll 的调用点与测试同步更新。
- "Start 即结束"路径:`ServerLoop.CreateRoomForSeats` 持有 `Room` 引用,直接读
`room.EndResult`
### 3. 网关(`Server/Gateway/ServerLoop.cs`)
`OnRoomEnded(string roomId, RoomEndResult result)`:
```csharp
var msg = new RoomEndMsg {
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
};
```
### 4. RPS 服务端(`Server/games-src/RPS/RPS.Server/RpsServerRoom.cs`)
- `OnRoomStart` 记录 `_pidOfSeat[2]`(座位→pid;AI 玩家也有 pid)。
- 结束时(`Phase == Finished`):
- `Winner == 2`(平局)→ `WinnerPlayerId = 0`,文本 `"平局 {s0} : {s1}"`;
- 否则 → `WinnerPlayerId = _pidOfSeat[winner]`,文本 `"最终比分 {s0} : {s1}"`
- 文本按座位序,双端同文本(广播);UTF-8 编码进 `ResultBlob`,调 `EndRoom(result)`
### 5. 客户端弹框(`Client/Assets/Script/xmain/MiniGame/MiniGameHost.cs`,框架层)
RPS 客户端零改动。
- `ResultTitle`:
- `winner == _selfPlayerId`(非 0)→ "胜利";
- `winner != 0` 且非自己 → "失败";
- `winner == 0`:blob 有文本 → "平局";blob 空 → "游戏结束"
(旧模块/异常结束无结果时保持中性标题,不误报平局)。
- `ResultMessage`:blob 解出非空文本 → 只显示 blob 文本(去掉裸 pid 行);
空 → 维持现状回退(房间号)。
## 测试(TDD)
- **Server.Host.Tests**:①`RoomCtx.EndRoom(result)``Room.EndResult` 捕获;
`TickAll` 返回的 `EndedRoom` 携带结果;③未设结果时 `Result == null`;
`RpsServerRoomTests`:对局打完,以正确胜者 pid 与比分文本调了 `EndRoom`
(含平局→pid 0 分支)。
- **Gateway.Tests**:RPS 端到端断言收到的 RoomEnd 帧 `WinnerPlayerId`/`ResultBlob`
非占位;`build-test-games.sh` 重建 rps 测试夹具(RPS.Server 源码变更)。
- **客户端**(本环境无 Unity):IDE 诊断/csc 0 错;用户 Unity Play 一局人工核对弹框
(胜利/失败/平局标题 + 比分文本,点确定正常退出)。
## 部署影响
- 服务端:网关 + Server.Host 一体重编部署即生效。
- RPS 服务器模块:须经发布流水线重发 `deploy/minigame/rps/<ver>/`
(旧包兼容不崩,只是弹框无结算内容)。
- 客户端:`XWorld.Link``XWorld.Framework.Shared` 热更 DLL 重出。
## 不做(YAGNI)
- `SetResult()` 两步接口(无场景);
- 结构化结算(每回合出招记录等,用户选了胜负+比分);
- 宿主强制结束时的结果通道(结果恒 null → 弹框中性"游戏结束",可接受)。