Files
AIC-Project/docs/superpowers/plans/2026-07-09-room-end-result.md
2026-07-10 10:24:29 +08:00

23 KiB

结算结果传递(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.csServer/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>):

[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):

// 结束时带结算结果的房间
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 全文替换为:

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 _endRoomAction<RoomEndResult> _endRoom;末尾两方法:

public void EndRoom() => _endRoom(null);
public void EndRoom(RoomEndResult result) => _endRoom(result);

Server/Server.Host/Room.cs:RequestEnd 处(第 24-26 行)改为:

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 返回类型变更会同时破坏 ServerLoopRoomHostTests 编译,故三处一个任务内完成。结果真值(非 null)的断言在 Task 3 夹具重建后补,本任务先保证管道形状与零回归。

  • Step 1: 适配测试(先改测试即 RED——编译失败)

RoomHostTests.csTickAll_ReturnsEndedRoomIds(第 77-90 行)改为:

[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 类之前)加:

// 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 行)替换为:

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

var ended = _host.TickAll(dt);
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);

第 405 行("Start 即结束"路径,CreateRoomForSeats 持有 Room 引用):

if (!room.IsActive) OnRoomEnded(roomId, room.EndResult); // 极端:Start 即结束

OnRoomEnded(第 408-421 行)替换为:

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 行)改为四元组、捕获结果:

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;,追加两个新测试:

// ── 结算结果:胜者 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 之后)加:

private readonly int[] _pidOfSeat = new int[2]; // seat->playerId(结算用;AI 也有 pid)

OnRoomStart 循环体(第 23-27 行)改为:

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

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/1built rps v2 -> ...TestGames ready.(脚本内含 Framework.Shared.dll 不泄漏断言,失败会 exit 1)。

  • Step 6: 夹具层结果断言(RoomHostTests)

RoomHostTests.cs 追加(1 真人 + 1 AI 随机对局,可能平局,故只断言 blob 非空):

[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 行)改:

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

// 结算结果: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 行后)加:

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(...) 之前加一行:

string resultText = DecodeResultBlob(roomEnd.ResultBlob);

Title/Message 两处 CreateText 调用改为传入 resultText:

Text title = CreateText("Title", panel.transform, ResultTitle(roomEnd, resultText), 34, FontStyle.Bold, TextAnchor.MiddleCenter);
Text message = CreateText("Message", panel.transform, ResultMessage(roomEnd, resultText), 22, FontStyle.Normal, TextAnchor.MiddleCenter);
  • Step 2: 替换 ResultTitle / ResultMessage
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.EndResultRequestEnd(RoomEndResult)EndedRoom{RoomId,Result}OnRoomEnded(string,RoomEndResult)ResultTitle/ResultMessage(RoomEndMsg,string) 各任务间引用一致。
  • 确定性核对:Finish_ReportsWinnerPidAndScoreText 每回合双方显式出招,无随机补招;6 回合 ×10.2s=61.2s≥60 触发 Finish,比分 6:0 确定。