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
@@ -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 → 弹框中性"游戏结束",可接受)。