Files
AIC-Project/docs/superpowers/plans/2026-06-22-remove-hello-complete-rps.md
T
2026-07-10 10:24:29 +08:00

49 KiB
Raw Blame History

删除 Hello + 完善 RPS 替代它 实施计划

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: 彻底删除测试夹具 Hello,让剪刀石头布(RPS)同时承担"框架测试夹具"和"端到端示例游戏(含 C# 客户端)"两个角色,且 dotnet test Server/Server.sln 保持全绿。

Architecture: RPS 服务端(RPS.Core+RPS.Server)已完整且已有 RPS 专属测试(逻辑/房间/ALC加载/匹配/端到端)。本计划把"借 Hello 验证框架通用能力"的测试迁到 RPS(部分直接删冗余),用 rps/1+rps/2(同 IL、版本号不同)替代 Hello 的多版本夹具,新增对标 HelloFlowGameClient 的 RPS C# 客户端薄层,删除弃用 Lua,并把 PC 烟雾测试改用 RPS。

Tech Stack: C# / .NET 10(测试 xUnit)、netstandard2.1(游戏 DLL/客户端薄层)、bash 构建脚本、Unity 2022.3 + IMGUI(客户端,本环境仅 csc 编译验证)。

重要约定(本仓库非 git): 跳过所有 git commit 步骤;每个 Task 末尾以 dotnet test / dotnet build 的实际输出作为检查点。本环境无 Unity:客户端与 PC 烟雾的 Unity 行为无法 Play 验证,只能编译验证 + 标注人工核对。


File Structure

修改:

  • Server/build-test-games.sh — 去掉 Hello 构建,RPS 同时产出 rps/1rps/2
  • Server/Server.Host.Tests/GameModuleLoaderTests.cs — 去冗余,仅保留 rps 缺版本测试
  • Server/Server.Host.Tests/AlcUnloadTests.cs — 改 rps
  • Server/Server.Host.Tests/GameModuleRegistryTests.cs — 改 rps 1/2
  • Server/Server.Host.Tests/GameManifestTests.cs — Valid 常量改 rps
  • Server/Server.Host.Tests/RoomHostTests.cs — 重写为 rps 语义
  • Server/Gateway.Tests/ServerLoopTests.cs — 删冗余 + 重连/在房重复匹配改 rps
  • Server/Gateway.Tests/GatewayEndToEndTests.cs — 删冗余 + 重连改 rps
  • Server/Gateway.Tests/MatchmakerTests.cs — playerCount=1 用例去 hello 标签
  • Server/Server.Host/GameManifest.cs — 文档注释示例改 RPS
  • Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1 — Hello → RPS
  • Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs — gameId/文案 hello → rps
  • Client/Assets/MiniGames/RockPaperScissors/README.md — 状态更新

新建:

  • Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs — C# 客户端薄层(唯一源码位)
  • Client/HotUpdateGames/RPS.Client/RPS.Client.csproj — 链接上面源码做 csc 验证(对标 HelloFlow.Client)

删除:

  • Server/games-src/Hello/(整目录)
  • Client/HotUpdateGames/HelloFlow.Client/(整目录)
  • Server/Server.Host.Tests/TestGames/hello/(夹具,及 bin 内拷贝)
  • Server/Server.Host.Tests/HotReloadEndToEndTests.cs
  • Client/Assets/MiniGames/RockPaperScissors/Client/main.lua.../Server/main.lua.../scripts/Client/main.lua.../scripts/Server/main.lua(及 .meta)

Task 1: 构建脚本去 Hello、RPS 产出 rps/1 + rps/2

Files:

  • Modify: Server/build-test-games.sh(整文件替换)

  • Step 1: 用以下内容整体替换 Server/build-test-games.sh

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"

build_rps () {
  local RPS_SRC="$ROOT/Server/games-src/RPS"
  local RPS_BIN="$RPS_SRC/RPS.Server/bin/Release/netstandard2.1"
  local TG="$ROOT/Server/Server.Host.Tests/TestGames/rps"

  # 不使用 -o-o 会绕过 Private=false,把 Framework.Shared.dll 也拷进来。
  dotnet build "$RPS_SRC/RPS.Server/RPS.Server.csproj" -c Release --no-incremental >/dev/null

  # 断言框架程序集未进入构建输出(Private=false 回归保险)
  if [ -f "$RPS_BIN/XWorld.Framework.Shared.dll" ]; then
    echo "ERROR: Framework.Shared.dll 出现在构建输出 $RPS_BIN —— Private=false 可能被破坏" >&2
    exit 1
  fi
  for dll in RPS.Core.dll RPS.Server.dll; do
    if [ ! -f "$RPS_BIN/$dll" ]; then
      echo "ERROR: 构建后缺少 $dll" >&2
      exit 1
    fi
  done

  # rps/1 = 真实版本;rps/2 = 同一批 IL(仅 version 字段不同),供版本号/淘汰/refcount 测试
  for ver in 1 2; do
    local out="$TG/$ver"
    rm -rf "$out"; mkdir -p "$out"
    cp "$RPS_BIN/RPS.Core.dll"   "$out/RPS.Core.dll"
    cp "$RPS_BIN/RPS.Server.dll" "$out/RPS.Server.dll"
    cat > "$out/game.json" <<EOF
{
  "gameId": "rps",
  "version": $ver,
  "serverAssembly": "RPS.Server.dll",
  "serverEntryType": "RPS.Server.RpsServerRoom",
  "minFrameworkVersion": 1,
  "playerCount": 2,
  "tickRateHz": 10
}
EOF
    echo "built rps v$ver -> $out"
  done
}

build_rps
echo "TestGames ready."
  • Step 2: 运行构建脚本

Run: bash Server/build-test-games.sh Expected: 输出 built rps v1 ...built rps v2 ...TestGames ready.,无 ERROR。

  • Step 3: 确认夹具产出且无框架 DLL 泄漏

Run: ls Server/Server.Host.Tests/TestGames/rps/1 Server/Server.Host.Tests/TestGames/rps/2 Expected: 两目录各含 RPS.Core.dllRPS.Server.dllgame.json;无 XWorld.Framework.Shared.dll


Task 2: 删除 Hello 源码、夹具与 C# 客户端

Files:

  • Delete: Server/games-src/Hello/Client/HotUpdateGames/HelloFlow.Client/Server/Server.Host.Tests/TestGames/hello/

  • Step 1: 删除 Hello 源码与客户端目录

Run:

rm -rf "Server/games-src/Hello"
rm -rf "Client/HotUpdateGames/HelloFlow.Client"
rm -rf "Server/Server.Host.Tests/TestGames/hello"
  • Step 2: 清理测试输出目录里残留的 hello 夹具拷贝(PreserveNewest 不会自动删除已移除文件)

Run:

find Server -type d -path "*/bin/*/TestGames/hello" -prune -exec rm -rf {} +

Expected: 无输出即成功(无残留或已删)。

  • Step 3: 确认 Hello 痕迹已无源码引用(仅测试 .cs 里还有字符串 "hello",下面任务逐个处理)

Run: grep -rln "Hello\." Server/games-src Client/HotUpdateGames 2>/dev/null || echo "no hello sources" Expected: no hello sources


Task 3: 删 HotReload 测试 + GameModuleLoaderTests 去冗余

RpsModuleLoadTests 已覆盖 "ALC 加载 + 跨界类型隔离 + 广播 + 推进到 Finished",故 GameModuleLoaderTests 的加载用例为冗余;HotReloadEndToEndTests 依赖两份不同 IL,按决策删除。

Files:

  • Delete: Server/Server.Host.Tests/HotReloadEndToEndTests.cs

  • Modify: Server/Server.Host.Tests/GameModuleLoaderTests.cs(整文件替换)

  • Modify: Server/Server.Host.Tests/RpsModuleLoadTests.cs:10(去掉注释里残留的 Hello 字样)

  • Step 1: 删除 HotReload 测试文件

Run: rm "Server/Server.Host.Tests/HotReloadEndToEndTests.cs"

  • Step 1b: 清理 RpsModuleLoadTests.cs 第 10 行注释里的 Hello 字样

把:

    /// Task 4: Proves "framework zero-change loads a new game via ALC" — same paradigm as Hello.

改为:

    /// Proves "framework zero-change loads a new game via ALC": loads RPS through the existing ALC contract.
  • Step 2: 用以下内容整体替换 GameModuleLoaderTests.cs
using Xunit;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class GameModuleLoaderTests
    {
        // ALC 加载 + 跨界类型隔离 + 广播已由 RpsModuleLoadTests 覆盖;此处只保留缺版本的失败路径。
        [Fact]
        public void Load_MissingVersion_Throws()
        {
            Assert.ThrowsAny<System.Exception>(
                () => new GameModuleLoader().Load(TestGames.Root, "rps", 99));
        }
    }
}
  • Step 3: 编译并运行该文件的测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleLoaderTests" Expected: PASS(1 个测试通过),无编译错误。


Task 4: AlcUnloadTests 改用 rps

Files:

  • Modify: Server/Server.Host.Tests/AlcUnloadTests.cs(整文件替换)

  • Step 1: 用以下内容整体替换 AlcUnloadTests.cs

using System;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Framework;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class AlcUnloadTests
    {
        private sealed class NullOutput : IRoomOutput
        {
            public void Broadcast(NetMessage m) { }
            public void SendTo(int playerId, NetMessage m) { }
        }

        // 在独立方法里加载/运行/卸载,确保局部强引用随方法返回而消失
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static (WeakReference alc, WeakReference asm) LoadRunAndUnload()
        {
            var module = new GameModuleLoader().Load(TestGames.Root, "rps", 1);
            IGameServerRoom room = module.CreateRoom();
            room.OnRoomStart(
                new[]
                {
                    new PlayerInfo { PlayerId = 1, Name = "H", IsAI = false },
                    new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true },
                },
                new RoomCtx(new DeterministicRandom(1), new ManualClock(), new ConsoleLogger(),
                    new InMemoryStorage(), new NullOutput(), () => { }));
            room.OnTick(1f); // dt=1 < 5s,仍在 Choosing,仅广播一次快照
            var asmWeak = new WeakReference(room.GetType().Assembly); // 游戏 ALC 内加载的程序集
            room = null;
            return (module.Unload(), asmWeak);
        }

        [Fact]
        public void GameModule_Unload_ReclaimsMemory()
        {
            var (alcWeak, asmWeak) = LoadRunAndUnload();
            for (int i = 0; i < 12 && (alcWeak.IsAlive || asmWeak.IsAlive); i++)
            {
                GC.Collect();
                GC.WaitForPendingFinalizers();
            }
            Assert.False(alcWeak.IsAlive, "游戏模块 ALC 未被回收 —— 存在泄漏引用");
            Assert.False(asmWeak.IsAlive, "游戏程序集未被回收 —— 存在泄漏引用");
        }
    }
}
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~AlcUnloadTests" Expected: PASS。


Task 5: GameModuleRegistryTests 改用 rps 1/2

rps/2rps/1 同 IL、版本号不同,正好驱动 refcount/淘汰/卸载逻辑(这些只看版本号,不看 IL 差异)。

Files:

  • Modify: Server/Server.Host.Tests/GameModuleRegistryTests.cs(整文件替换)

  • Step 1: 用以下内容整体替换 GameModuleRegistryTests.cs

using Xunit;
using XWorld.Server.Host;

namespace XWorld.Server.Host.Tests
{
    public class GameModuleRegistryTests
    {
        private static GameModuleRegistry NewRegistry()
            => new GameModuleRegistry(new GameModuleLoader(), TestGames.Root);

        [Fact]
        public void Acquire_SameVersionTwice_LoadsOnce_RefCounts()
        {
            var reg = NewRegistry();
            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 Release_ToZero_WithoutSupersede_KeepsLoaded()
        {
            var reg = NewRegistry();
            var m = reg.Acquire("rps", 1);
            reg.Release(m);
            Assert.Equal(0, reg.RefCount("rps", 1));
            Assert.True(reg.IsLoaded("rps", 1));   // 未被淘汰则保留以复用
        }

        [Fact]
        public void NewVersion_Supersedes_Old_And_OldUnloadsWhenRefZero()
        {
            var reg = NewRegistry();
            var v1 = reg.Acquire("rps", 1);        // v1 引用=1
            var v2 = reg.Acquire("rps", 2);        // 取 v2 → v1 被标记淘汰
            Assert.True(reg.IsLoaded("rps", 1));
            Assert.True(reg.IsLoaded("rps", 2));

            reg.Release(v1);                          // v1 引用归零且被淘汰 → 卸载并移除
            Assert.False(reg.IsLoaded("rps", 1));
            Assert.True(reg.IsLoaded("rps", 2));    // v2 仍在
        }

        [Fact]
        public void Acquire_FailedNewVersionLoad_DoesNotSupersedeExisting()
        {
            var reg = NewRegistry();
            var v1 = reg.Acquire("rps", 1);
            Assert.ThrowsAny<System.Exception>(() => reg.Acquire("rps", 99)); // v99 不存在,加载抛出
            reg.Release(v1);                       // v1 引用归零
            Assert.True(reg.IsLoaded("rps", 1)); // 未被错误淘汰,仍保留
        }

        [Fact]
        public void Release_MoreThanAcquired_Throws()
        {
            var reg = NewRegistry();
            var m = reg.Acquire("rps", 1);
            reg.Release(m);
            Assert.Throws<System.InvalidOperationException>(() => reg.Release(m));
        }
    }
}
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameModuleRegistryTests" Expected: PASS(5 个测试)。


Task 6: GameManifestTests 的 Valid 常量改 rps

Files:

  • Modify: Server/Server.Host.Tests/GameManifestTests.cs:9-30

  • Step 1: 替换 Valid 常量(原 9-17 行)为:

        private const string Valid = @"{
            ""gameId"": ""rps"",
            ""version"": 2,
            ""serverAssembly"": ""RPS.Server.dll"",
            ""serverEntryType"": ""RPS.Server.RpsServerRoom"",
            ""minFrameworkVersion"": 1,
            ""playerCount"": 2,
            ""tickRateHz"": 10
        }";
  • Step 2: 替换 Parse_Valid_ReadsAllFields 方法体(原 20-30 行)为:
        public void Parse_Valid_ReadsAllFields()
        {
            var m = GameManifest.Parse(Valid);
            Assert.Equal("rps", m.GameId);
            Assert.Equal(2, m.Version);
            Assert.Equal("RPS.Server.dll", m.ServerAssembly);
            Assert.Equal("RPS.Server.RpsServerRoom", m.ServerEntryType);
            Assert.Equal(1, m.MinFrameworkVersion);
            Assert.Equal(2, m.PlayerCount);
            Assert.Equal(10, m.TickRateHz);
        }

(其余测试 Parse_CaseInsensitive_FieldNamesParse_Invalid_Throws 与游戏无关,不改。)

  • Step 3: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GameManifestTests" Expected: PASS。


Task 7: 重写 RoomHostTests 为 rps 语义

Hello 用"计数器、第 3 tick 结束、单人"建模;RPS 用"回合制、60s 结束、2 人(含 AI)、出招 opcode=1"。用 RpsLogic.Decode 读快照(本项目已引用 RPS.Core)。

Files:

  • Modify: Server/Server.Host.Tests/RoomHostTests.cs(整文件替换)

  • Step 1: 用以下内容整体替换 RoomHostTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using RPS.Core;

namespace XWorld.Server.Host.Tests
{
    public class RoomHostTests
    {
        private sealed class Capture : IRoomOutput
        {
            public List<NetMessage> Broadcasts = new List<NetMessage>();
            public void Broadcast(NetMessage m) => Broadcasts.Add(m);
            public void SendTo(int playerId, NetMessage m) { }
        }

        // 1 真人(seat 0) + 1 AI(seat 1)
        private static readonly IReadOnlyList<PlayerInfo> Players = new[]
        {
            new PlayerInfo { PlayerId = 1,  Name = "H",  IsAI = false },
            new PlayerInfo { PlayerId = -1, Name = "AI", IsAI = true  },
        };

        private static RpsState LastState(Capture cap)
            => new RpsLogic().Decode(cap.Broadcasts[cap.Broadcasts.Count - 1].Payload);

        private static RoomHost NewHost()
            => new RoomHost(new GameModuleRegistry(new GameModuleLoader(), TestGames.Root), new ConsoleLogger());

        private static RoomConfig Cfg()
            => new RoomConfig { GameId = "rps", Version = 1, Seed = 1, PlayerCount = 2 };

        [Fact]
        public void CreateRoom_TickToEnd_ReleasesModule()
        {
            var host = NewHost();
            var cap = new Capture();

            var room = host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);
            Assert.Equal(1, host.ActiveRoomCount);

            // 大 dt 推进:60s 对局在数个大 tick 内结束
            for (int i = 0; i < 10 && host.ActiveRoomCount > 0; i++) host.TickAll(10f);

            Assert.Equal(0, host.ActiveRoomCount);
            Assert.False(room.IsActive);
            Assert.Equal(RpsPhase.Finished, LastState(cap).Phase);
        }

        [Fact]
        public void Deliver_RoutesChoice_AffectsState()
        {
            var host = NewHost();
            var cap = new Capture();
            host.CreateRoom("A", "rps", 1, Cfg(), Players, cap);

            // seat 0(玩家 1)出 Rockopcode=1(ChoiceOpcode)payload=单字节 choice
            var w = new PacketWriter();
            w.WriteByte((byte)Choice.Rock);
            host.DeliverTo("A", 1, new NetMessage(1, w.ToArray()));
            host.TickAll(0.1f); // 小 dt,仍在 Choosing,广播快照

            Assert.Equal(Choice.Rock, LastState(cap).Choices[0]);
        }

        [Fact]
        public void CreateRoom_DuplicateRoomId_Throws()
        {
            var host = NewHost();
            host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture());
            Assert.Throws<System.InvalidOperationException>(
                () => host.CreateRoom("dup", "rps", 1, Cfg(), Players, new Capture()));
        }

        [Fact]
        public void TickAll_ReturnsEndedRoomIds()
        {
            var host = NewHost();
            host.CreateRoom("E", "rps", 1, Cfg(), Players, new Capture());

            var first = host.TickAll(10f); // 第一大 tickTotalElapsed=10 < 60,不结束
            Assert.Empty(first);

            var ended = new List<string>();
            for (int i = 0; i < 10 && !ended.Contains("E"); i++)
                ended.AddRange(host.TickAll(10f));
            Assert.Contains("E", ended); // 累计超过 60s 后该 tick 返回结束房间 id
        }
    }
}
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~RoomHostTests" Expected: PASS(4 个测试)。


Task 8: ServerLoopTests 去冗余 + 重连/在房重复匹配改 rps

删除 hello 专属的 Join_RepliesMatchFound_...GameInput_RoutedToRoom_...(已被 ServerLoopMatchmakingTests 覆盖);保留与游戏无关的心跳/过期/无房间帧用例;把重连、在房重复匹配改为 rps 2 人;MatchRequest_WithNoSession 的 gameId 改 rps。

Files:

  • Modify: Server/Gateway.Tests/ServerLoopTests.cs(整文件替换)

  • Step 1: 用以下内容整体替换 ServerLoopTests.cs

using System.Collections.Generic;
using Xunit;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class ServerLoopTests
    {
        private static ServerLoop NewLoop(long reconnectWindow = 100)
            => new ServerLoop(TestGames.Root, new ConsoleLogger("[loop]"), reconnectWindow);

        private static byte[] JoinFrame(string gameId, int version)
            => FrameCodec.Encode(new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
                new MatchRequestMsg { GameId = gameId, Version = version }.Encode()));

        private static List<Frame> Frames(FakeConnection c)
        {
            var list = new List<Frame>();
            foreach (var bytes in c.Sent) list.Add(FrameCodec.Decode(bytes));
            return list;
        }

        [Fact]
        public void Heartbeat_IsEchoed()
        {
            var loop = NewLoop();
            var c = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
            var hb = new HeartbeatMsg { ClientTimeMs = 123456789L };
            loop.Submit(new FrameCommand { PlayerId = 1,
                Frame = new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat, hb.Encode()) });

            loop.DrainAndTick(0f);

            var echo = Frames(c).Find(f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat);
            Assert.NotEqual(default, echo);
            Assert.Equal(123456789L, HeartbeatMsg.Decode(echo.Payload).ClientTimeMs);
        }

        [Fact]
        public void Reconnect_ReroutesRoomOutputToNewConnection()
        {
            var loop = NewLoop(reconnectWindow: 100);
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(2);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
            loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            loop.DrainAndTick(1f); // 2 人成团:c1/c2 收到 MatchFound + 快照
            Assert.True(c1.Sent.Count >= 2);

            // pid=1 断线后重连到新连接 c1b
            loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c1 });
            var c1b = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1b });
            loop.DrainAndTick(1f); // 房间仍在跑(60s 未到):下一快照应发往 c1b

            Assert.Contains(Frames(c1b), f => f.Channel == Channel.Game);
        }

        [Fact]
        public void ExpiredSession_IsSwept()
        {
            var loop = NewLoop(reconnectWindow: 2);
            var c = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
            loop.DrainAndTick(1f); // _tick: 0→1
            loop.Submit(new DisconnectCommand { PlayerId = 1, Connection = c });
            loop.DrainAndTick(1f); // 排空时 _tick=1 → DisconnectedAtTick=1;随后 _tick→2
            loop.DrainAndTick(1f); // _tick→33-1=2,不 >2,保留
            loop.DrainAndTick(1f); // _tick→44-1=3 >2 → 过期清除
            Assert.False(loop.HasSession(1));
        }

        [Fact]
        public void DuplicateMatchRequest_WhileInRoom_IsRejectedWithError()
        {
            var loop = NewLoop();
            var c1 = new FakeConnection(1);
            var c2 = new FakeConnection(2);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c1 });
            loop.Submit(new ConnectCommand { PlayerId = 2, Connection = c2 });
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            loop.Submit(new FrameCommand { PlayerId = 2, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            loop.DrainAndTick(1f); // 2 人入房

            // 已在房间内再次 join → 回 Error
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            loop.DrainAndTick(1f);

            Assert.Contains(Frames(c1), f => f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Error);
        }

        [Fact]
        public void GameFrame_WithNoRoom_IsIgnoredSafely()
        {
            var loop = NewLoop();
            var c = new FakeConnection(1);
            loop.Submit(new ConnectCommand { PlayerId = 1, Connection = c });
            var w = new PacketWriter(); w.WriteVarInt(3);
            loop.Submit(new FrameCommand { PlayerId = 1, Frame = new Frame(Channel.Game, 0, w.ToArray()) });

            var ex = Record.Exception(() => loop.DrainAndTick(1f));
            Assert.Null(ex);       // 无房间的游戏帧被安全忽略,不抛
            Assert.Empty(c.Sent);  // 无任何输出
        }

        [Fact]
        public void MatchRequest_WithNoSession_IsIgnoredSafely()
        {
            var loop = NewLoop();
            // 未发 ConnectCommand,直接来一个 join 帧
            loop.Submit(new FrameCommand { PlayerId = 99, Frame = FrameCodec.Decode(JoinFrame("rps", 1)) });
            var ex = Record.Exception(() => loop.DrainAndTick(1f));
            Assert.Null(ex);
            Assert.False(loop.HasSession(99));
        }
    }
}
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~ServerLoopTests" Expected: PASS(6 个测试)。注意 filter ~ServerLoopTests 也会匹配 ServerLoopMatchmakingTests/ServerLoopSignatureEnforcementTests,它们应一并通过。


Task 9: GatewayEndToEndTests 去冗余 + 重连改 rps(AI 兜底)

删除 hello e2e(已被 RpsEndToEndTests 覆盖);心跳用例与游戏无关保留;重连用例改为 rps 单人 + AI 兜底,小 logicalDt 让 60s 对局慢跑以留出重连时间。

Files:

  • Modify: Server/Gateway.Tests/GatewayEndToEndTests.cs(整文件替换)

  • Modify: Server/Gateway.Tests/Gateway.Tests.csproj:27(注释去 Hello 字样)

  • Step 0: 更新 Gateway.Tests.csproj 第 27 行注释

把:

    <!-- 复用 2a 已构建的 Hello 夹具(由 build-test-games.sh 生成到 Server.Host.Tests/TestGames -->

改为:

    <!-- 复用 build-test-games.sh 生成到 Server.Host.Tests/TestGames 的 rps 夹具 -->
  • Step 1: 用以下内容整体替换 GatewayEndToEndTests.cs
using System.Threading.Tasks;
using Xunit;
using XWorld.Framework.Protocol;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class GatewayEndToEndTests
    {
        private static Frame JoinRps()
            => new Frame(Channel.Framework, (ushort)FrameworkOpcode.MatchRequest,
                new MatchRequestMsg { GameId = "rps", Version = 1 }.Encode());

        [Fact]
        public async Task Heartbeat_IsEchoedBack()
        {
            var host = new GameServerHost();
            await host.StartAsync(TestGames.Root, tickIntervalMs: 20);
            try
            {
                using var client = new WsTestClient();
                await client.ConnectAsync(host.WsBaseUrl, pid: 7);
                await client.SendFrameAsync(new Frame(Channel.Framework, (ushort)FrameworkOpcode.Heartbeat,
                    new HeartbeatMsg { ClientTimeMs = 999L }.Encode()));

                Frame? echo = null;
                for (int i = 0; i < 10; i++)
                {
                    var f = await client.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.Heartbeat) { echo = f; break; }
                }
                Assert.True(echo.HasValue, "未收到心跳回显");
                Assert.Equal(999L, HeartbeatMsg.Decode(echo.Value.Payload).ClientTimeMs);
                await client.CloseAsync();
            }
            finally { await host.StopAsync(); }
        }

        [Fact]
        public async Task Reconnect_ContinuesReceivingFromSameRoom()
        {
            var host = new GameServerHost();
            // matchTimeoutTicks=3 → 单人后 AI 兜底成团;logicalDt=0.2 让 60s 对局慢跑;大重连窗口
            await host.StartAsync(TestGames.Root, tickIntervalMs: 200,
                reconnectWindowTicks: 100, matchTimeoutTicks: 3, logicalDt: 0.2f);
            try
            {
                var client1 = new WsTestClient();
                await client1.ConnectAsync(host.WsBaseUrl, pid: 42);
                await client1.SendFrameAsync(JoinRps());

                // 收到 MatchFound 后的第一个游戏快照
                bool gotSnapshot = false;
                for (int i = 0; i < 15 && !gotSnapshot; i++)
                {
                    var f = await client1.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Game) gotSnapshot = true;
                }
                Assert.True(gotSnapshot, "重连前应先收到一个快照");

                // 断开
                await client1.CloseAsync();
                client1.Dispose();

                // 用同 pid 重连
                using var client2 = new WsTestClient();
                await client2.ConnectAsync(host.WsBaseUrl, pid: 42);

                // 房间仍在跑,下一个房间输出应发到 client2
                bool gotAfterReconnect = false;
                for (int i = 0; i < 15 && !gotAfterReconnect; i++)
                {
                    var f = await client2.ReceiveFrameAsync(5000);
                    if (f.Channel == Channel.Game) gotAfterReconnect = true;
                    else if (f.Channel == Channel.Framework && f.Opcode == (ushort)FrameworkOpcode.RoomEnd) gotAfterReconnect = true;
                }
                Assert.True(gotAfterReconnect, "重连后应继续收到房间输出(快照或 RoomEnd)");
                await client2.CloseAsync();
            }
            finally { await host.StopAsync(); }
        }
    }
}
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~GatewayEndToEndTests" Expected: PASS(2 个测试)。


Task 10: MatchmakerTests 的 playerCount=1 用例去 hello 标签

Matchmaker 是纯队列逻辑,不加载具体游戏,gameId 仅作分桶标签,改成非 hello 字符串即可。

Files:

  • Modify: Server/Gateway.Tests/MatchmakerTests.cs:63-75

  • Step 1: 替换 PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch 方法(原 63-75 行)为:

        [Fact]
        public void PlayerCount1_SingleEnqueue_PollImmediately_ReturnsMatch()
        {
            // playerCount=1 应立即成团(Matchmaker 仅按 playerCount 分桶,不加载具体游戏)
            var mm = new Matchmaker(timeoutTicks: 100);
            mm.Enqueue(pid: 5, gameId: "solo1p", version: 1, playerCount: 1, tick: 0);

            var matches = mm.Poll(currentTick: 0);

            Assert.Single(matches);
            Assert.Single(matches[0].Seats);
            Assert.False(matches[0].Seats[0].IsAi);
        }
  • Step 2: 运行该测试

Run: dotnet test Server/Server.sln --filter "FullyQualifiedName~MatchmakerTests" Expected: PASS(5 个测试)。


Task 10b: 修正受 rps/2 影响的 lobby 发现测试(执行期发现的计划缺口)

引入 rps/2 后,ServerLoop.BuildGameList() 按"每 gameId 最新版本"发现,大厅改列 rps v2,导致既有测试 ServerLoopMatchmakingTests.Lobby_GameList_AndRandomMatch_AssignsDiscoverableGameVersion == 1 断言失败。版本号非该测试重点,改为版本无关 + 断言"随机匹配分配大厅所列版本"。

Files:

  • Modify: Server/Gateway.Tests/ServerLoopMatchmakingTests.cs(Lobby 测试两处断言)

  • Step 1:Assert.Contains(list.Games, g => g.GameId == "rps" && g.Version == 1); 改为版本无关并捕获 rps 版本:

            // 两个 rps 夹具(v1/v2,同 IL)并存时,大厅只列出最新版本;具体版本号非本测试重点
            Assert.Contains(list.Games, g => g.GameId == "rps");
            int rpsVersion = 0;
            foreach (var g in list.Games) if (g.GameId == "rps") rpsVersion = g.Version;
  • Step 2:Assert.Equal(1, assigned.Version); 改为 Assert.Equal(rpsVersion, assigned.Version);
  • Step 3: Run dotnet test Server/Gateway.Tests/Gateway.Tests.csproj → 全绿。

Task 11: 全量服务端回归(删 Hello 后第一道总闸)

Files: 无(仅运行)

  • Step 1: 运行整个解决方案测试

Run: dotnet test Server/Server.sln Expected: 全部 PASS,0 失败。失败项必为某处仍引用 hello 夹具或断言未适配——回到对应 Task 修正后再跑。


Task 12: 新增 RPS C# 客户端薄层(对标 HelloFlowGameClient)

源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);另建一个 RPS.Client.csproj(放 Client/HotUpdateGames/,与 HelloFlow.Client 同级)用 <Compile Include> 链接同一批源做 csc 验证。客户端按现有 RpsState 字段(座位制,无 net id)呈现,不扩协议。

Files:

  • Create: Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs

  • Create: Client/HotUpdateGames/RPS.Client/RPS.Client.csproj

  • Step 1: 创建 Client/Assets/MiniGames/RockPaperScissors/scripts/Client/RpsGameClient.cs

using RPS.Core;
using UnityEngine;
using XWorld.Framework;
using XWorld.Framework.Protocol;

namespace RPS.Client
{
    // RPS 客户端薄层:解码服务端快照、渲染 IMGUI、在 Choosing 阶段提交一次出招。
    // 协议:服务端广播 SnapshotOpcode=1;客户端出招 ChoiceOpcode=1payload=单字节 Choice。
    public sealed class RpsGameClient : IGameClient
    {
        private const ushort SnapshotOpcode = 1;
        private const ushort ChoiceOpcode = 1;

        private readonly RpsLogic _logic = new RpsLogic();
        private IGameClientCtx _ctx;
        private GameObject _viewObject;
        private RpsView _view;

        public void OnEnter(IGameClientCtx ctx)
        {
            _ctx = ctx;
            _viewObject = new GameObject("RpsView");
            Object.DontDestroyOnLoad(_viewObject);
            _view = _viewObject.AddComponent<RpsView>();
            _view.Client = this;
            _view.Status = "waiting for match...";
            _ctx.Logger.Info("rps client entered");
        }

        public void OnNetMessage(NetMessage msg)
        {
            if (msg.Opcode != SnapshotOpcode) return;
            var state = _logic.Decode(msg.Payload);
            if (_view == null) return;

            // 进入新一回合的 Choosing 时解锁本地出招
            if (state.Phase == RpsPhase.Choosing && _view.State != null
                && _view.State.Round != state.Round)
                _view.LocalChoice = Choice.None;

            _view.State = state;
            if (state.Phase == RpsPhase.Finished)
                _view.Status = state.Winner == 2 ? "finished: draw"
                             : "finished: seat " + state.Winner + " wins";
            else
                _view.Status = "round " + state.Round + " / " + state.Phase;
        }

        public void OnUpdate(float dt)
        {
            if (_view != null) _view.Elapsed += dt;
        }

        public void OnExit()
        {
            _ctx?.Logger.Info("rps client exited");
            if (_viewObject != null) Object.Destroy(_viewObject);
            _viewObject = null;
            _view = null;
            _ctx = null;
        }

        // 由视图按钮调用:Choosing 阶段提交一次出招(本回合只发一次)
        public void SubmitChoice(Choice choice)
        {
            if (_ctx == null || _view == null || _view.State == null) return;
            if (_view.State.Phase != RpsPhase.Choosing) return;
            if (_view.LocalChoice != Choice.None) return;
            _view.LocalChoice = choice;
            var w = new PacketWriter();
            w.WriteByte((byte)choice);
            _ctx.Send(new NetMessage(ChoiceOpcode, w.ToArray()));
            _view.Status = "locked: " + choice;
        }
    }

    public sealed class RpsView : MonoBehaviour
    {
        public RpsGameClient Client;
        public string Status;
        public RpsState State;
        public Choice LocalChoice = Choice.None;
        public float Elapsed;

        private static string ChoiceName(Choice c)
            => c == Choice.Rock ? "Rock"
             : c == Choice.Paper ? "Paper"
             : c == Choice.Scissors ? "Scissors" : "-";

        private void OnGUI()
        {
            GUILayout.BeginArea(new Rect(16, 16, 460, 320), GUI.skin.box);
            GUILayout.Label("Rock-Paper-Scissors (Hot DLL)");
            GUILayout.Label("Status: " + Status);
            GUILayout.Label("Elapsed: " + Elapsed.ToString("0.0") + "s");

            if (State == null)
            {
                GUILayout.Label("No snapshot yet");
            }
            else
            {
                GUILayout.Label("Round: " + State.Round + "   Phase: " + State.Phase);
                GUILayout.Label("Score: seat0 " + State.Scores[0] + " : " + State.Scores[1] + " seat1");
                GUILayout.Label("This round: seat0=" + ChoiceName(State.Choices[0])
                                + "  seat1=" + ChoiceName(State.Choices[1]));

                bool canChoose = State.Phase == RpsPhase.Choosing && LocalChoice == Choice.None;
                GUI.enabled = canChoose;
                GUILayout.BeginHorizontal();
                if (GUILayout.Button("Rock")) Client?.SubmitChoice(Choice.Rock);
                if (GUILayout.Button("Paper")) Client?.SubmitChoice(Choice.Paper);
                if (GUILayout.Button("Scissors")) Client?.SubmitChoice(Choice.Scissors);
                GUILayout.EndHorizontal();
                GUI.enabled = true;

                if (LocalChoice != Choice.None)
                    GUILayout.Label("Your choice: " + ChoiceName(LocalChoice));
                if (State.Phase == RpsPhase.Finished)
                    GUILayout.Label("Winner: " + (State.Winner == 2 ? "draw" : "seat " + State.Winner));
            }

            GUILayout.EndArea();
        }
    }
}
  • Step 2: 创建 Client/HotUpdateGames/RPS.Client/RPS.Client.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <AssemblyName>RPS.Client</AssemblyName>
    <RootNamespace>RPS.Client</RootNamespace>
    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
    <UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f2c1\Editor</UnityEditorPath>
  </PropertyGroup>

  <ItemGroup>
    <!-- 客户端薄层源码唯一位置在 Assets 下(Unity/HybridCLR 编译它);本工程仅链接同一批源做 csc 验证 -->
    <Compile Include="..\..\Assets\MiniGames\RockPaperScissors\scripts\Client\**\*.cs" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\..\Server\Framework.Shared\Framework.Shared.csproj">
      <Private>false</Private>
    </ProjectReference>
    <ProjectReference Include="..\..\..\Server\games-src\RPS\RPS.Core\RPS.Core.csproj">
      <Private>false</Private>
    </ProjectReference>
  </ItemGroup>

  <ItemGroup>
    <Reference Include="UnityEngine">
      <HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll</HintPath>
      <Private>false</Private>
    </Reference>
    <Reference Include="UnityEngine.CoreModule">
      <HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
      <Private>false</Private>
    </Reference>
    <Reference Include="UnityEngine.IMGUIModule">
      <HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.IMGUIModule.dll</HintPath>
      <Private>false</Private>
    </Reference>
  </ItemGroup>

</Project>
  • Step 3: csc 编译验证(0 错误)

Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo Expected: Build succeeded,0 Error。若报 UnityEngine 找不到,确认本机 D:\Program Files\Unity 2022.3.62f2c1\Editor 存在(与 HelloFlow.Client.csproj 同路径);路径不同则传 -p:UnityEditorPath=<你的Unity路径>

  • Step 4: 确认未把框架/Core/Unity DLL 拷进输出(Private=false 生效)

Run: ls Client/HotUpdateGames/RPS.Client/bin/Release/netstandard2.1/ Expected: 只有 RPS.Client.dll(及 pdb/deps.json),无 XWorld.Framework.Shared.dllRPS.Core.dllUnityEngine*.dll

备注:RpsGameClient.cs 在 Unity 中需要 .meta,由 Unity 导入时自动生成,本环境不手工创建。仍须 Unity 人工核对:运行时表现、HybridCLR 加载、AB 打包。


Task 13: 删除弃用 Lua + 更新 README

Files:

  • Delete: Client/Assets/MiniGames/RockPaperScissors/Client/main.lua(+.meta)、.../Server/main.lua(+.meta)、.../scripts/Client/main.lua(+.meta)、.../scripts/Server/main.lua(+.meta)

  • Modify: Client/Assets/MiniGames/RockPaperScissors/README.md:15-22

  • Step 1: 删除四份 Lua 及其 .meta,并清理空目录

Run:

cd "Client/Assets/MiniGames/RockPaperScissors"
rm -f Client/main.lua Client/main.lua.meta Server/main.lua Server/main.lua.meta
rm -f scripts/Server/main.lua scripts/Server/main.lua.meta
rm -rf Client Client.meta Server Server.meta scripts/Server scripts/Server.meta
cd -

(注:scripts/Client/main.lua 不在此删 —— 下一步会被 Task 12 的 RpsGameClient.cs 取代;若仍存在则一并删。)

  • Step 2: 删除残留的 scripts/Client/main.lua(C# 已取代)

Run:

rm -f "Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua" \
      "Client/Assets/MiniGames/RockPaperScissors/scripts/Client/main.lua.meta"
  • Step 3: 替换 README 的"当前状态"小节(原 15-22 行)为:
## 当前状态(C# 客户端已落地)

- `res/UI/Texture/`:三张出招贴图(rock / paper / scissors),将随 AB 打包。
- `scripts/Client/`:客户端薄层 C# 实现 `RpsGameClient``IGameClient`),由 Unity/HybridCLR 编译为热更 DLL
  另有 `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` 链接同一份源做 csc 编译验证。
- 共享核心 `RPS.Core`(规则/状态/编解码)与服务端薄层 `RPS.Server``IGameServerRoom`)的 C# 源码在
  `Server/games-src/RPS/`(旧 Lua 版已删除)。
- 待办(独立改造,须 Unity):将 `RPS.Core` 收敛为 `scripts/Core` 单源、服务端 `<Compile Include>` 链接(设计 §10.1)。
  • Step 4: 确认 Lua 已清除

Run: find "Client/Assets/MiniGames/RockPaperScissors" -name "*.lua" Expected: 无输出。


Task 14: PC 烟雾测试改用 RPS

Files:

  • Modify: Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1(整文件替换)

  • Modify: Client/Assets/Script/xmain/MiniGame/PcMiniGameSmokeLauncher.cs:7,13,48

  • Modify: Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs:30

  • Step 1: 用以下内容整体替换 Tools/PcMiniGameSmoke/Build-PcMiniGameSmoke.ps1

param(
    [string]$UnityEditorPath = "D:\Program Files\Unity 2022.3.62f2c1\Editor",
    [string]$OutRoot = "Build\PcMiniGameSmoke"
)

$ErrorActionPreference = "Stop"

function Md5Hex([string]$Path) {
    $md5 = [System.Security.Cryptography.MD5]::Create()
    try {
        $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $Path))
        $hash = $md5.ComputeHash($bytes)
        return -join ($hash | ForEach-Object { $_.ToString("x2") })
    }
    finally {
        $md5.Dispose()
    }
}

function WriteMd5Named([string]$Source, [string]$LogicalName, [string]$DestDir) {
    $hash = Md5Hex $Source
    $ext = [System.IO.Path]::GetExtension($LogicalName)
    $nameNoExt = if ($ext.Length -gt 0) { $LogicalName.Substring(0, $LogicalName.Length - $ext.Length) } else { $LogicalName }
    $destName = if ($ext.Length -gt 0) { "${nameNoExt}_${hash}${ext}" } else { "${LogicalName}_${hash}" }
    Copy-Item -LiteralPath $Source -Destination (Join-Path $DestDir $destName) -Force
    $size = (Get-Item -LiteralPath $Source).Length
    return "{'$LogicalName','$hash',$size},"
}

$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$out = Join-Path $repo $OutRoot
$serverDir = Join-Path $out "server\games\rps\1"
$clientDir = Join-Path $out "client\minigame\rps\1"
$stageDir = Join-Path $out "_stage"

New-Item -ItemType Directory -Force -Path $serverDir, $clientDir, $stageDir | Out-Null
foreach ($dir in @($serverDir, $clientDir, $stageDir)) {
    Get-ChildItem -LiteralPath $dir -Force | Remove-Item -Recurse -Force
}

dotnet build (Join-Path $repo "Server\games-src\RPS\RPS.Server\RPS.Server.csproj") -c Release -nologo | Write-Host
dotnet build (Join-Path $repo "Client\HotUpdateGames\RPS.Client\RPS.Client.csproj") -c Release -nologo -p:UnityEditorPath="$UnityEditorPath" | Write-Host

$rpsCore = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Core.dll"
$rpsServer = Join-Path $repo "Server\games-src\RPS\RPS.Server\bin\Release\netstandard2.1\RPS.Server.dll"
$rpsClient = Join-Path $repo "Client\HotUpdateGames\RPS.Client\bin\Release\netstandard2.1\RPS.Client.dll"

Copy-Item -LiteralPath $rpsCore -Destination (Join-Path $serverDir "RPS.Core.dll") -Force
Copy-Item -LiteralPath $rpsServer -Destination (Join-Path $serverDir "RPS.Server.dll") -Force

@"
{
  "gameId": "rps",
  "version": 1,
  "serverAssembly": "RPS.Server.dll",
  "serverEntryType": "RPS.Server.RpsServerRoom",
  "minFrameworkVersion": 1,
  "playerCount": 2,
  "tickRateHz": 10
}
"@ | Set-Content -Encoding UTF8 (Join-Path $serverDir "game.json")

@"
{
  "gameId": "rps",
  "version": 1,
  "clientEntryType": "RPS.Client.RpsGameClient",
  "coreDll": "RPS.Core.dll",
  "clientDll": "RPS.Client.dll",
  "minFrameworkVersion": 1,
  "assets": []
}
"@ | Set-Content -Encoding UTF8 (Join-Path $clientDir "game.json")

$coreBytes = Join-Path $stageDir "RPS.Core.dll.bytes"
$clientBytes = Join-Path $stageDir "RPS.Client.dll.bytes"
Copy-Item -LiteralPath $rpsCore -Destination $coreBytes -Force
Copy-Item -LiteralPath $rpsClient -Destination $clientBytes -Force

$lines = @()
$lines += WriteMd5Named $coreBytes "RPS.Core.dll.bytes" $clientDir
$lines += WriteMd5Named $clientBytes "RPS.Client.dll.bytes" $clientDir
$lines -join "`n" | Set-Content -Encoding UTF8 (Join-Path $clientDir "files.txt")
Set-Content -Encoding Byte -Value @() (Join-Path $clientDir "files.txt.sig")

Write-Host "PC smoke package ready:"
Write-Host "  server games root: $((Resolve-Path (Join-Path $out 'server\games')).Path)"
Write-Host "  client cdn base: file:///$($repo.Replace('\','/'))/$($OutRoot.Replace('\','/'))/client/minigame/"
Write-Host "Run server (rps 为 2 人,单人入房需 AI 兜底,故用较小 matchTimeoutTicks):"
Write-Host "  dotnet Server\Gateway.Runner\bin\Debug\net10.0\XWorld.Server.Gateway.Runner.dll --gamesRoot `"$((Resolve-Path (Join-Path $out 'server\games')).Path)`" --port 5005 --tickMs 100 --matchTimeoutTicks 30"
  • Step 2: 改 PcMiniGameSmokeLauncher.cs 第 7 行注释

把:

    // PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 hello 小游戏。

改为:

    // PC 本地端到端烟测入口:连接本地 Gateway,再用 MiniGameHost 下载/加载 rps 小游戏。
  • Step 3: 改第 13 行默认 GameId

把:

        public string GameId = "hello";

改为:

        public string GameId = "rps";
  • Step 4: 改第 48 行 Expected 文案

把:

            GUILayout.Label("Expected: download hello DLLs -> connect -> MatchFound -> 3 snapshots -> RoomEnd.");

改为:

            GUILayout.Label("Expected: download rps DLLs -> connect -> MatchFound(+AI) -> snapshots -> RoomEnd.");
  • Step 4b: 改 Client/Assets/Script/Editor/PcMiniGameSmokeMenu.cs 第 30 行默认 GameId

把:

            launcher.GameId = "hello";

改为:

            launcher.GameId = "rps";
  • Step 5: 验证 .NET 构建段(Unity/AB/Play 段无法在本环境验)

Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo && dotnet build "Server/games-src/RPS/RPS.Server/RPS.Server.csproj" -c Release -nologo Expected: 两个 Build succeeded,0 Error。

须人工核对:在 Windows + Unity 下运行 Build-PcMiniGameSmoke.ps1 与 Play 烟测(本环境无法执行 PowerShell/Unity)。


Task 15: GameManifest.cs 文档注释改 RPS

Files:

  • Modify: Server/Server.Host/GameManifest.cs:10-11

  • Step 1: 替换第 10-11 行注释

把:

        public string ServerAssembly { get; set; }   // 例如 "Hello.Server.dll"
        public string ServerEntryType { get; set; }  // 例如 "Hello.Server.HelloServerRoom"

改为:

        public string ServerAssembly { get; set; }   // 例如 "RPS.Server.dll"
        public string ServerEntryType { get; set; }  // 例如 "RPS.Server.RpsServerRoom"
  • Step 2: 确认 Server.Host 仍编译

Run: dotnet build Server/Server.Host/Server.Host.csproj -c Debug -nologo Expected: Build succeeded


Task 16: 最终全量回归 + Hello 痕迹清零核对

Files: 无(仅运行)

  • Step 1: 全量测试

Run: dotnet test Server/Server.sln Expected: 全部 PASS,0 失败。

  • Step 2: 确认本项目代码内已无 Hello 引用(只扫我们维护的目录,排除第三方 Unity 包/无关示例串)

Run:

grep -rIln "[Hh]ello\|HELLO" \
  Server/Server.Host Server/Gateway Server/games-src Server/Server.Host.Tests Server/Gateway.Tests \
  Server/build-test-games.sh Tools/PcMiniGameSmoke \
  Client/HotUpdateGames Client/Assets/MiniGames \
  Client/Assets/Script/xmain/MiniGame Client/Assets/Script/Editor \
  2>/dev/null | grep -v "/bin/" | grep -v "/obj/" || echo "no hello references"

Expected: no hello references

说明:仓库其他位置(Client/Library/PackageCacheClient/PackageLocalAES.csWX.cslsocket.csXWMain.luasandbox_spec.lua 等)出现的 "hello" 是第三方 Unity 包测试或无关示例字符串,不在本计划范围。

  • Step 3: 确认 RPS C# 客户端编译通过

Run: dotnet build "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj" -c Release -nologo Expected: Build succeeded,0 Error。


完成标准

  • dotnet test Server/Server.sln 全绿(0 失败)。
  • Client/HotUpdateGames/RPS.Client/RPS.Client.csproj csc 编译 0 错误。
  • 仓库内无 Hello 源码、夹具、测试引用、构建脚本引用(文档目录除外)。
  • 弃用 Lua 已删,README 已更新。
  • PC 烟雾的 .NET 段构建通过;Unity Play/AB 段标注为人工核对。

遗留(本环境无 Unity,无法验证)

  • RPS C# 客户端运行时表现、HybridCLR 加载、AB 打包。
  • Build-PcMiniGameSmoke.ps1 实际运行 + Play 烟测。
  • RPS.Core 收敛为 scripts/Core 单源(设计 §10.1,独立改造项)。