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,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 确定。